diff --git a/package-lock.json b/package-lock.json index 686ebdc..527dcbf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@mantine/core": "9.6.1", "@mantine/dates": "9.6.1", "@mantine/hooks": "9.6.1", + "@mantine/notifications": "9.6.1", "@tanstack/react-query": "5.102.8", "@wealthledger/icons": "file:packages/icons", "@wealthledger/tokens": "file:packages/tokens", @@ -2841,6 +2842,31 @@ "react": "^19.2.0" } }, + "node_modules/@mantine/notifications": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.6.1.tgz", + "integrity": "sha512-6bikkVCAf6wd1KO/oVhBuXGDZ+HcWeNGCEvdgkL0pQ6jmjzd24ShW6UnbGu6aLABhYxQdfLnrE1cbOjC93Tj6A==", + "license": "MIT", + "dependencies": { + "@mantine/store": "9.6.1", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "9.6.1", + "@mantine/hooks": "9.6.1", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, + "node_modules/@mantine/store": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.6.1.tgz", + "integrity": "sha512-KePeOIAg2n7wkHlxFxF7s6qYeD9EE/GrFIK3cc329nTSDIEIJQ+oFQv1S/t4sZJTIz7YnBlRuvA9Wc8pRg8TRA==", + "license": "MIT", + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", @@ -7275,7 +7301,6 @@ "node_modules/csstype": { "version": "3.2.3", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -7749,6 +7774,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv": { "version": "17.4.2", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", @@ -10715,7 +10750,6 @@ "node_modules/js-tokens": { "version": "4.0.0", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -11363,6 +11397,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "3.2.1", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", @@ -11819,6 +11865,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", @@ -12536,6 +12591,23 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/protocols": { "version": "2.0.2", "integrity": "sha512-hHVTzba3wboROl0/aWRRG9dMytgH6ow//STBZh43l/wQgmMhYhOFi0EHWAPtoCz9IAUymsyP0TSBHkhgMEGNnQ==", @@ -13001,6 +13073,22 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", diff --git a/package.json b/package.json index 8c0daa1..a43bf4e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@mantine/core": "9.6.1", "@mantine/dates": "9.6.1", "@mantine/hooks": "9.6.1", + "@mantine/notifications": "9.6.1", "@tanstack/react-query": "5.102.8", "@wealthledger/icons": "file:packages/icons", "@wealthledger/tokens": "file:packages/tokens", diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 023debe..1226035 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -369,7 +369,9 @@ describe("All accounts routes", () => { renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31"); await screen.findAllByText("Выплата купона"); fireEvent.click(screen.getByRole("button", { name: "Далее" })); - fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); + await waitFor(() => { + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + }); expect(await screen.findAllByText("Выплата купона")).toHaveLength(2); expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( "from=2026-08-01&to=2026-08-31", @@ -390,10 +392,12 @@ describe("All accounts routes", () => { }), ); expect(screen.getAllByText("Сбербанк")).toHaveLength(1); - expect( - await screen.findByRole("button", { - name: "Повторить", - }), - ).toBeInTheDocument(); + await waitFor(() => { + expect( + screen.queryByRole("button", { + name: "Повторить", + }), + ).not.toBeInTheDocument(); + }); }); }); diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx index db26d02..4a196cc 100644 --- a/src/app/ui/App.tsx +++ b/src/app/ui/App.tsx @@ -2,6 +2,7 @@ import type { FC } from "react"; import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; +import { Notifications } from "@mantine/notifications"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { BrowserRouter } from "react-router"; @@ -11,7 +12,11 @@ import { AuthProvider } from "@/features/auth"; const queryClient = new QueryClient({ defaultOptions: { - queries: { refetchOnWindowFocus: false, retry: false }, + queries: { + refetchOnReconnect: true, + refetchOnWindowFocus: true, + retry: false, + }, }, }); @@ -31,6 +36,8 @@ const App: FC = () => { + + diff --git a/src/entities/bank-card/api/banking.test.tsx b/src/entities/bank-card/api/banking.test.tsx index 2ec2666..86fd5b4 100644 --- a/src/entities/bank-card/api/banking.test.tsx +++ b/src/entities/bank-card/api/banking.test.tsx @@ -147,7 +147,7 @@ describe("banking API", () => { expect(url.searchParams.get("from")).toBe("2026-09-01"); expect(url.searchParams.get("to")).toBe("2026-09-30"); if (isFail && page === 2) - return HttpResponse.json({ code: "FAILED" }, { status: 500 }); + return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); return HttpResponse.json({ hasNext: page === 1, @@ -299,7 +299,7 @@ describe("banking API", () => { expect(result.current.query.data?.balance.value).toBe("100"); server.use( http.get("*/api/v1/bank-cards/a", () => - HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }), + HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }), ), ); await act(() => result.current.query.refetch()); diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts index 4add66a..20fbbe5 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts +++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.vm.ts @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { notifications } from "@mantine/notifications"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -71,7 +72,7 @@ function useBankCardFormVm(props: BankCardFormProps) { openingDate: values.openingDate, }; - if (props.card) + if (props.card) { await updateCard.mutateAsync({ body: { ...metadata, @@ -80,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) { }, id: props.card.id, }); - else + } + else { await cardCreation.mutateAsync({ id: intent, ...metadata, ...financial, }); + notifications.show({ + id: "bank-card-created-" + intent, + message: t("bank.cardSaved"), + }); + } + setOpened(false); } catch (error_) { diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx index 897713b..1ed7fd9 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx @@ -47,7 +47,6 @@ const meta error: false, fetching: false, loading: false, - retry: () => {}, }, component: BankSummaryView, parameters: { diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx index 4bd575f..52fccc6 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx @@ -1,6 +1,6 @@ import type { CSSProperties, FC } from "react"; -import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { Skeleton, Stack, Text, Title } from "@mantine/core"; import { OperationIcon } from "@wealthledger/icons"; import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; @@ -20,7 +20,6 @@ type BankSummaryViewProps = { error: boolean; fetching: boolean; loading: boolean; - retry: () => void; }; const BankSummaryViewCategories: FC = (props) => { @@ -101,17 +100,7 @@ const BankSummaryView: FC = (props) => { - {props.error && ( - - {t(props.data ? "bank.refreshError" : "bank.error")} - - - - )} - - {props.data && props.fetching && {t("bank.refreshing")}} + {props.error && !props.data && {t("bank.error")}} ); }; diff --git a/src/entities/broker-operation/model/useBrokerageOperationHistory.ts b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts index 3147b8c..1ab9a25 100644 --- a/src/entities/broker-operation/model/useBrokerageOperationHistory.ts +++ b/src/entities/broker-operation/model/useBrokerageOperationHistory.ts @@ -1,5 +1,5 @@ import { hashKey, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; @@ -29,15 +29,28 @@ function useBrokerageOperationHistory( cursor: cursors.at(-1), }); + const isInvalidCursor = isApiErrorCode( + apiErrorCode(operations.error), + ApiErrorCode.InvalidOperationsCursor, + ); + + const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor; + const reset = () => setPagination({ cursors: [], scope }); + useEffect(() => { + if (!isRecoveringInvalidCursor) return; + + const timeout = setTimeout(() => { + setPagination({ cursors: [], scope }); + void client.invalidateQueries({ exact: true, queryKey }); + }, 0); + + return () => clearTimeout(timeout); + }, [client, isRecoveringInvalidCursor, queryKey, scope]); + const retry = (retryScreen: () => unknown) => { - if ( - isApiErrorCode( - apiErrorCode(operations.error), - ApiErrorCode.InvalidOperationsCursor, - ) - ) { + if (isInvalidCursor) { reset(); void client.invalidateQueries({ exact: true, @@ -49,6 +62,7 @@ function useBrokerageOperationHistory( return { hasPrevious: cursors.length > 0, + isRecoveringInvalidCursor, onNext: () => { if (operations.data?.nextCursor) setPagination({ diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx index 40e3734..5855d0a 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx @@ -1,12 +1,10 @@ import type { FC } from "react"; -import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; +import { Button, Skeleton, Stack, Text } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; -import { SourceSyncStatus } from "@/shared/ui"; - import type { BrokerageEvent } from "../../api/useBrokerageEvents"; import { brokerageEventKey } from "../../model/event-key"; @@ -19,9 +17,6 @@ type BrokerageEventsPreviewProps = { type BrokerageEventsPreviewState = | { items: BrokerageEvent[]; - lastSuccessfulAt?: null | string; - refreshError?: boolean; - stale?: boolean; state: "loaded" | "partial"; to: string; } @@ -33,28 +28,15 @@ const BrokerageEventsPreview: FC = (props) => { const { events } = props; const { t } = useTranslation(); - const errorText - = events.state === "loaded" && events.refreshError - ? t("accounts.refreshError") - : null; - return ( - {events.state === "loaded" && ( - - )} - {events.state === "loading" && ( {["first", "second", "third"].map(placeholder => )} )} - {events.state === "error" && {t("events.recentError")}} + {events.state === "error" && {t("states.unavailableText")}} {events.state === "empty" && {t("events.recentEmpty")}} diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index b2ff22d..40b5527 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { resource } from "@/test/resource"; @@ -13,9 +13,7 @@ describe("instrument source and transport presentation", () => { const state = section({ data, isError: false, - isFetching: false, meta: resource(data, { availability }).meta, - refetch: vi.fn(), }); expect(state.loading).toBe(availability === "missing"); @@ -23,39 +21,40 @@ describe("instrument source and transport presentation", () => { expect(state.hasData).toBe(availability !== "missing"); }, ); - it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => { - const retry = vi.fn(); - const refetch = vi.fn(); + it("preserves empty successful data instead of treating it as missing", () => { + const state = section({ + data: [], + isError: false, + meta: resource([]).meta, + }); + expect(state).toMatchObject({ + error: false, + hasData: true, + loading: false, + }); + }); + + it("preserves a full source when force fails", () => { const meta = resource([], { sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, }).meta; - const state = section( - { data: [], isError: false, isFetching: false, meta, refetch }, - retry, - ); + const state = section({ data: [], isError: false, meta }); expect(state).toMatchObject({ error: true, 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, isError: false, - isFetching: false, meta: resource(null, { sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, }).meta, - refetch: vi.fn(), }); expect(state).toMatchObject({ diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index 399d903..898ea88 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -3,43 +3,27 @@ import type { InvestmentResourceMeta } from "@/shared/api"; type SectionState = { error: boolean; hasData: boolean; - lastSuccessfulAt?: null | string; loading: boolean; partial?: boolean; - refreshing: boolean; - retry: () => void; - sourceError?: boolean; - stale?: boolean; }; -function section( - query: { - data: unknown; - isError: boolean; - isFetching: boolean; - meta?: InvestmentResourceMeta; - refetch: () => unknown; - }, - retry?: () => unknown, -): SectionState { +function hasSectionData(data: unknown) { + return data !== undefined; +} + +function section(query: { + data: unknown; + isError: boolean; + meta?: InvestmentResourceMeta; +}): SectionState { const isError = query.isError || query.meta?.sync.state === "failed"; + const hasData = hasSectionData(query.data); return { error: isError, - hasData: Boolean(query.data), - lastSuccessfulAt: query.meta?.lastSuccessfulAt, - loading: !query.data && !isError, + hasData, + loading: !hasData && !isError, partial: query.meta?.availability === "partial", - refreshing: - Boolean(query.data) - && (query.isFetching - || query.meta?.sync.state === "running" - ), - retry: () => { - void (retry ?? query.refetch)(); - }, - sourceError: query.meta?.sync.state === "failed", - stale: query.meta?.freshness === "stale", }; } diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx index 6d6b75d..13e3f98 100644 --- a/src/features/instrument-details/ui/InstrumentEvents.view.tsx +++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx @@ -86,7 +86,7 @@ const InstrumentEvents: FC = (props) => { {children} diff --git a/src/features/instrument-details/ui/InstrumentPageState.view.tsx b/src/features/instrument-details/ui/InstrumentPageState.view.tsx index aa6fb61..1f502ee 100644 --- a/src/features/instrument-details/ui/InstrumentPageState.view.tsx +++ b/src/features/instrument-details/ui/InstrumentPageState.view.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; -import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { Anchor, Skeleton, Stack, Text, Title } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -11,11 +11,10 @@ type InstrumentPageStateProps = { code?: string; kind: "bond" | "stock"; loading: boolean; - retry: () => void; }; const InstrumentPageState: FC = (props) => { - const { code, kind, loading, retry } = props; + const { code, kind, loading } = props; const { t } = useTranslation(); if (loading) return ( @@ -55,10 +54,6 @@ const InstrumentPageState: FC = (props) => { {t("accounts.openSettings")} - - ); }; diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index 64694b9..5c37ba1 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -2,7 +2,6 @@ import type { FC, PropsWithChildren, ReactNode } from "react"; import { Alert, - Button, Group, Paper, Skeleton, @@ -13,8 +12,6 @@ import { import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -import { SourceSyncStatus } from "@/shared/ui"; - import type { SectionState } from "../model/section"; import classes from "./InstrumentDetails.module.css"; @@ -33,10 +30,6 @@ type InstrumentSectionProps = PropsWithChildren<{ const InstrumentSection: FC = (props) => { const { t } = useTranslation(); - const readError = t( - props.state.hasData ? "accounts.refreshError" : "stock.sectionError", - ); - const sectionClassName = props.embedded ? classes.embeddedSection : classes.section; @@ -80,35 +73,11 @@ const InstrumentSection: FC = (props) => { {props.controls} - - {!!props.state.partial && {t("investment.partial")}} - {!!props.state.refreshing && ( - - {t("accounts.refreshing")} - - )} - - {!!props.state.error && ( + {!!(props.state.error && !props.state.hasData) && ( - - - {props.state.sourceError ? t("investment.syncFailed") : readError} - - - - + {t("stock.sectionError")} )} diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx index 4f16310..1bdeb77 100644 --- a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx +++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.test.tsx @@ -1,7 +1,7 @@ import type { ComponentProps } from "react"; import { MantineProvider } from "@mantine/core"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { expect, it, vi } from "vitest"; @@ -16,8 +16,6 @@ it("keeps cached results on failure, hides them during a new search and distingu partial: false, query: "SBER", ready: true, - refreshing: false, - retry: vi.fn(), }; const cached = [Сбербанк]; @@ -44,8 +42,7 @@ it("keeps cached results on failure, hides them during a new search and distingu expect( screen.getByRole("link", { name: "Открыть настройки T-Bank" }), ).toHaveAttribute("href", "/settings"); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(vm.retry).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); rerender(view({ loading: true, ready: false })); expect(screen.queryByRole("link", { name: "Сбербанк" })).not.toBeInTheDocument(); diff --git a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx index e56ff5f..b99ed45 100644 --- a/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx +++ b/src/features/instrument-search/ui/InstrumentSearch/InstrumentSearch.view.tsx @@ -3,7 +3,6 @@ import type { ChangeEvent, FC, ReactNode } from "react"; import { Alert, Anchor, - Button, Skeleton, Stack, Text, @@ -28,8 +27,6 @@ type InstrumentSearchProps = { partial: boolean; query: string; ready: boolean; - refreshing: boolean; - retry: () => void; }; }; @@ -77,14 +74,6 @@ const InstrumentSearch: FC = (props) => { - - {!!vm.partial && {t("investment.partial")}} = (props) => { {t("accounts.openSettings")} - )} diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index dc39898..aef200c 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -78,7 +78,7 @@ describe("page-owned screen synchronization", () => { expect(posted).toEqual(["old", "new"]); }); it.each([false, true])( - "retries a failed GET without forcing the source (cached=%s)", + "recovers a failed GET without forcing the source (cached=%s)", async (cached) => { auth(); const modes: (null | string)[] = []; @@ -122,26 +122,13 @@ describe("page-owned screen synchronization", () => { expect(result.current.accounts.isSuccess).toBe(true), ); isUnavailable = true; - await act(() => result.current.accounts.refetch()); + void result.current.accounts.refetch(); } - await waitFor(() => expect(result.current.accounts.isError).toBe(true)); isUnavailable = false; - await act(() => result.current.sync.retry()); await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true)); - expect(result.current.accounts.meta?.dataRevision).toBe( - cached ? "3" : "2", - ); - expect(modes).toEqual( - cached - ? [ - null, - null, - "observe", - ] - : [null, "observe"], - ); expect(posts).toBe(0); + expect(modes.every(mode => mode === null)).toBe(true); expect(result.current.disabled.data).toBeUndefined(); expect( queryClient @@ -157,171 +144,193 @@ describe("page-owned screen synchronization", () => { ["connection", false], ["access", false], ] as const)( - "discards recovery after %s changes (HTTP already pending=%s)", + "discards source recovery after %s changes (HTTP already pending=%s)", async (boundary, pending) => { auth(); const { promise: blocked, resolve: release } = Promise.withResolvers(); - let isRecovering = false; + let recoveryRequests = 0; let isChanged = false; - server.use( - http.get("*/api/v1/brokerage-accounts", async ({ request }) => { - if (boundary === "connection" && isChanged && !request.signal.aborted) - return HttpResponse.json(resource([], { dataRevision: "current" })); - if (!new URL(request.url).searchParams.has("sync")) - return isChanged - ? HttpResponse.json(resource([], { dataRevision: "current" })) - : HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }); - isRecovering = true; - await blocked; - return HttpResponse.json(resource([], { dataRevision: "old" })); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); - const published: unknown[] = []; + try { + server.use( + http.get("*/api/v1/brokerage-accounts", async () => { + if (isChanged) + return HttpResponse.json( + resource([], { dataRevision: "current" }), + ); - const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if (event.type === "updated" && event.action.type === "success") - published.push(event.query.state.data); - }); + if (recoveryRequests > 0) { + recoveryRequests += 1; + await blocked; - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); + return HttpResponse.json(resource([], { dataRevision: "old" })); + } - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts]), - }; - }, - { wrapper }, - ); + recoveryRequests += 1; - await waitFor(() => expect(result.current.accounts.isError).toBe(true)); - let recovery!: Promise; - act(() => { - recovery = result.current.sync.retry(); - }); - // 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 () => { - 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?.dataRevision).toBe("current"), + return HttpResponse.json( + resource(null, { + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: "failed", + state: "failed", + }, + }), + ); + }), ); - expect(published).not.toContainEqual( - expect.objectContaining({ - meta: expect.objectContaining({ dataRevision: "old" }) as unknown, - }), - ); - unsubscribe(); + const { queryClient, wrapper } = createQueryWrapper(); + const published: unknown[] = []; + + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if (event.type === "updated" && event.action.type === "success") + published.push(event.query.state.data); + }); + + const { result } = renderHook( + () => { + const accounts = useBrokerageAccounts(); + + return { + accounts, + sync: useScreenSync({ screen: "accounts" }, [accounts]), + }; + }, + { wrapper }, + ); + + await waitFor(() => + expect(result.current.accounts.meta?.sync.state).toBe("failed"), + ); + vi.useFakeTimers(); + if (pending) + await act(() => vi.advanceTimersByTimeAsync(5000)); + expect(recoveryRequests).toBeGreaterThanOrEqual(1); + + await act(async () => { + 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(); + if (!pending) await vi.advanceTimersByTimeAsync(5000); + }); + vi.useRealTimers(); + if (boundary === "connection" && !pending) + 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?.dataRevision).toBe("current"), + ); + expect(published).not.toContainEqual( + expect.objectContaining({ + meta: expect.objectContaining({ dataRevision: "old" }) as unknown, + }), + ); + unsubscribe(); + } + finally { + vi.useRealTimers(); + } }, ); it.each([false, true])( - "recovers an initial GET failure after forced sync without metadata (status retry=%s)", + "recovers an initial GET failure without forced sync (status retry=%s)", async (retryStatus) => { auth(); const modes: (null | string)[] = []; + let posts = 0; let polls = 0; - 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: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json(resource([])); - }), - http.post("*/api/v1/tbank-sync", () => - HttpResponse.json({ id: "recover" }, { status: 202 }), - ), - http.get("*/api/v1/tbank-sync/:id", () => { - polls += 1; + try { + server.use( + http.get("*/api/v1/brokerage-accounts", ({ request }) => { + modes.push(new URL(request.url).searchParams.get("sync")); - return retryStatus && polls === 1 - ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json({ - id: "recover", - state: "succeeded", - }); - }), - ); - const { queryClient, wrapper } = createQueryWrapper(); + return modes.length === 1 + ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource([])); + }), + http.post("*/api/v1/tbank-sync", () => { + posts += 1; - const { result } = renderHook( - () => { - const accounts = useBrokerageAccounts(); - const disabled = useStockQuote(""); + return HttpResponse.json({ id: "recover" }, { status: 202 }); + }), + http.get("*/api/v1/tbank-sync/:id", () => { + polls += 1; - return { - accounts, - sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]), - }; - }, - { wrapper }, - ); - - await waitFor(() => expect(result.current.accounts.isError).toBe(true)); - expect(result.current.accounts.meta).toBeUndefined(); - 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), + return retryStatus && polls === 1 + ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json({ + id: "recover", + state: "succeeded", + }); + }), ); - await act(() => result.current.sync.retry()); + const { queryClient, wrapper } = createQueryWrapper(); + + const { result } = renderHook( + () => { + const accounts = useBrokerageAccounts(); + const disabled = useStockQuote(""); + + return { + accounts, + sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]), + }; + }, + { wrapper }, + ); + + await waitFor( + () => expect(result.current.accounts.isSuccess).toBe(true), + { timeout: 3000 }, + ); + expect(result.current.accounts.data).toEqual([]); + + expect(modes).toEqual([null, null]); + expect(posts).toBe(0); + expect(polls).toBe(0); + expect( + queryClient + .getQueryCache() + .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }), + ).toHaveLength(1); + } + finally { + vi.useRealTimers(); } - - await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true)); - expect(result.current.accounts.data).toEqual([]); - - expect(modes[0]).toBeNull(); - expect(modes.slice(1).every(mode => mode === "observe")).toBe(true); - expect( - queryClient - .getQueryCache() - .findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }), - ).toHaveLength(1); }, ); it("posts only its scope once and observes completion without importing again", async () => { diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 00db54a..0809222 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -1,4 +1,9 @@ -import { type QueryKey, useQueryClient } from "@tanstack/react-query"; +import { + focusManager, + onlineManager, + type QueryKey, + useQueryClient, +} from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { @@ -21,9 +26,39 @@ type ScreenResource = { meta?: InvestmentResourceMeta; observe: () => Promise; queryKey: QueryKey; + refetch: () => Promise; }; const STATUS_INTERVAL_MS = 1000; +const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000; +const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000; + +function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean { + if (!meta || meta.unavailableReason) return false; + + return ["cancelled", "failed"].includes(meta.sync.state) + || meta.availability === "missing" + || meta.freshness === "stale" + || (meta.dependencies ?? []).some(dependency => + requiresSourceRecovery(dependency), + ); +} + +function sourceRecoveryDelay(attempt: number): number | undefined { + switch (attempt) { + case 0: { + return FIRST_SOURCE_RECOVERY_DELAY_MS; + } + + case 1: { + return SECOND_SOURCE_RECOVERY_DELAY_MS; + } + + default: { + return undefined; + } + } +} function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { @@ -35,6 +70,9 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { const scopeKey = JSON.stringify(scope); const requestInFlightRef = useRef(null); + const lifecycleRecoveryRef = useRef(new Set()); + const sourceRecoveryRef = useRef(new Map()); + const sourceTimersRef = useRef(new Set>()); const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; @@ -110,6 +148,73 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { void resource.observe().catch(() => {}); }, [client, statuses, resources, scopeKey]); + useEffect(() => { + const timers = sourceTimersRef.current; + const attempts = sourceRecoveryRef.current; + + return () => { + for (const timer of timers) clearTimeout(timer); + timers.clear(); + attempts.clear(); + }; + }, [scopeKey]); + + useEffect(() => { + for (const resource of resources) { + if ( + !requiresSourceRecovery(resource.meta) + || resource.isFetching + ) continue; + + const key = JSON.stringify([scopeKey, resource.queryKey]); + const attempt = sourceRecoveryRef.current.get(key) ?? 0; + const delay = sourceRecoveryDelay(attempt); + if (delay === undefined) continue; + + sourceRecoveryRef.current.set(key, attempt + 1); + + const timer = setTimeout(() => { + sourceTimersRef.current.delete(timer); + void resource.refetch().catch(() => {}); + }, delay); + + sourceTimersRef.current.add(timer); + } + }, [resources, scopeKey]); + + useEffect(() => { + const recover = () => { + for (const resource of resources) { + if ( + !requiresSourceRecovery(resource.meta) + || resource.isFetching + ) continue; + + const key = JSON.stringify([scopeKey, resource.queryKey]); + if (lifecycleRecoveryRef.current.has(key)) continue; + + sourceRecoveryRef.current.delete(key); + lifecycleRecoveryRef.current.add(key); + void resource.refetch() + .catch(() => {}) + .finally(() => lifecycleRecoveryRef.current.delete(key)); + } + }; + + const unsubscribeFocus = focusManager.subscribe((focused) => { + if (focused) recover(); + }); + + const unsubscribeOnline = onlineManager.subscribe((online) => { + if (online) recover(); + }); + + return () => { + unsubscribeFocus(); + unsubscribeOnline(); + }; + }, [resources, scopeKey]); + const isRefreshing = (isCurrentMutation && mutation.isPending) || statuses.some( diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx index 6d7d1b6..334d210 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx @@ -127,9 +127,11 @@ describe("TbankConnectionSection", () => { const token = screen.getByLabelText("Token T-Bank"); fireEvent.change(token, { target: { value: "replacement-draft" } }); finishRefetch?.(); - expect( - await screen.findByText(/Не удалось обновить статус подключения/), - ).toBeVisible(); + await waitFor(() => { + expect( + screen.queryByText(/Не удалось обновить статус подключения/), + ).not.toBeInTheDocument(); + }); expect(screen.getByLabelText("Token T-Bank")).toBe(token); expect(token).toHaveValue("replacement-draft"); expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible(); @@ -139,7 +141,6 @@ describe("TbankConnectionSection", () => { expect(client.getQueryData(portfolioQueryKey)).toEqual({ source: "previous-connection", }); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); await waitFor(() => expect(reads).toBe(2)); await waitFor(() => expect( @@ -164,13 +165,12 @@ describe("TbankConnectionSection", () => { }), ); renderSection(createTestQueryClient()); - expect( - await screen.findByText(/Не удалось загрузить статус подключения/), - ).toBeVisible(); - expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(await screen.findByLabelText("Token T-Bank")).toBeVisible(); - expect(reads).toBe(2); + await waitFor(() => { + expect( + screen.queryByText(/Не удалось загрузить статус подключения/), + ).not.toBeInTheDocument(); + }); + expect(reads).toBe(1); }); it("commits connection status and resets the complete brokerage query subtree after connecting", async () => { const client = seededClient(); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts index 5ec16f6..ac354e5 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.vm.ts @@ -1,3 +1,4 @@ +import { notifications } from "@mantine/notifications"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -32,6 +33,12 @@ function useTbankConnectionSectionLoadedVm(status: TbankConnectionStatus) { onSuccess: (data) => { commitTbankConnectionBoundary(queryClient, data); reset(); + + if (status.connected && data.connected) + notifications.show({ + id: "tbank-token-replaced-" + crypto.randomUUID(), + message: t("tbank.tokenReplaced"), + }); }, }); 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 4a75e0c..33015bf 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -3,7 +3,6 @@ import type { FC } from "react"; import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { - act, fireEvent, render, renderHook, @@ -115,7 +114,6 @@ describe("Upcoming event date filters", () => { "keeps event calendar dates in GET and source sync requests: %s", async (search) => { const requested: string[] = []; - const syncRequests: unknown[] = []; server.use( http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => { requested.push(new URL(request.url).search); @@ -133,16 +131,7 @@ describe("Upcoming event date filters", () => { ), ); }), - 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 queryClient = createTestQueryClient(); const { result } = renderHook( @@ -162,15 +151,6 @@ describe("Upcoming event date filters", () => { 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([ - { accountId: "all", screen: "events", ...period }, - ]), - ); }, ); @@ -232,7 +212,7 @@ describe("Upcoming event date filters", () => { ), ); renderEvents(accountId, ["?from=2099-02-01"]); - await screen.findByRole("button", { name: "Повторить" }); + await screen.findByRole("button", { name: "Сбросить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); @@ -300,7 +280,7 @@ describe("Upcoming event date filters", () => { ); }); -it("retains the previous complete forecast and timestamp when its source refresh fails", async () => { +it("retains the previous complete forecast when its source refresh fails", async () => { configureAuthClient({ accessToken: () => "synthetic", clear: vi.fn(), @@ -343,9 +323,6 @@ it("retains the previous complete forecast and timestamp when its source refresh await waitFor(() => expect(result.current).toMatchObject({ events, - 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 9308ae7..4bfa29e 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -159,8 +159,7 @@ describe("AccountEventsPage", () => { expect(screen.getAllByText("O")).toHaveLength(2); }); - it("renders empty and retryable error states", () => { - const refetch = vi.fn(); + it("renders empty and quiet error states", async () => { const { rerender } = renderPage(); expect( screen.getByText("За выбранный период событий не найдено"), @@ -169,7 +168,6 @@ describe("AccountEventsPage", () => { data: undefined, isError: true, isLoading: false, - refetch, } as never); rerender( @@ -178,8 +176,8 @@ describe("AccountEventsPage", () => { , ); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(refresh).toHaveBeenCalledOnce(); + expect(await screen.findByRole("button", { name: "Сбросить" })).toBeVisible(); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); it("shows a structural busy state before events are available", () => { diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index f9707e0..ce2a856 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -6,25 +6,20 @@ import { useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; -import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; +import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib"; type AccountEventsPageViewModel = | { canApply: boolean; draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; - lastSuccessfulAt?: null | string; onApply: () => void; onPeriodChange: (value: [null | string, null | string]) => void; onReset: () => void; partial?: boolean; - refreshError: boolean; - refreshing: boolean; - retry: () => void; - sourceError?: boolean; state: "loaded"; } - | { onReset: () => void; retry: () => void; state: "error" } + | { onReset: () => void; state: "error" } | { state: "loading" }; function useAccountEventsPageViewModel(): AccountEventsPageViewModel { @@ -56,7 +51,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { ...(appliedTo && { to: appliedTo }), }); - const sync = useScreenSync( + useScreenSync( { accountId, screen: "events", @@ -66,7 +61,15 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { [events], ); - if (events.isLoading) return { state: "loading" }; + const availability = useInitialAvailability({ + hasContent: !!events.data, + identity: scope, + isError: events.isError || events.meta?.sync.state === "failed", + isFetching: events.isFetching, + }); + + if (availability === "loading" && events.isLoading) + return { state: "loading" }; const onReset = () => { setDraftPeriod([null, null]); @@ -76,12 +79,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { if ((events.isError || events.meta?.sync.state === "failed") && !events.data) return { onReset, - retry: () => { - void sync.retry(); - }, state: "error", }; - if (!events.data) return { state: "loading" }; + if (!events.data) + return availability === "unavailable" + ? { onReset, state: "error" } + : { state: "loading" }; return { canApply: @@ -89,7 +92,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { && isValidPeriod(draftFrom, draftTo), draftPeriod, events: events.data.events, - lastSuccessfulAt: events.meta?.lastSuccessfulAt, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParameters({ @@ -100,12 +102,6 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { onPeriodChange: setDraftPeriod, onReset, partial: events.meta?.availability !== "complete", - refreshError: events.isError || sync.sourceError || sync.transportError, - refreshing: events.isFetching || sync.refreshing, - retry: () => { - void sync.retry(); - }, - sourceError: sync.sourceError, state: "loaded", }; } 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 9630812..86666b5 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx @@ -4,7 +4,7 @@ import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void }; +type AccountEventsPageErrorProps = { onReset: () => void }; const AccountEventsPageError: FC = (props) => { const { t } = useTranslation(); @@ -13,8 +13,6 @@ const AccountEventsPageError: FC = (props) => { - - 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 624ab0c..dd0743c 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -4,7 +4,6 @@ import { Alert, Button, Group, - Progress, Stack, Text, Title, @@ -16,7 +15,6 @@ import { BrokerageEventCard, brokerageEventKey, } from "@/entities/brokerage-event"; -import { SourceSyncStatus } from "@/shared/ui"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; @@ -55,32 +53,10 @@ const AccountEventsPageLoaded: FC = (props) => { return ( - - {t("events.title")} - - {!!props.partial && {t("investment.partial")}} - {!!props.refreshing && } - - {!!props.refreshError && ( - - {t( - props.sourceError - ? "investment.syncFailed" - : "accounts.refreshError", - )} - - - - )} - {t("events.hint")} diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 106aef7..8d1676d 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -195,7 +195,6 @@ describe("AccountIncomePage", () => { 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", @@ -216,30 +215,11 @@ describe("AccountIncomePage", () => { ); }, ), - 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: "Повторить" }); + await screen.findByText("Не удалось рассчитать пассивный доход"); expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]); - expect(syncRequests).toEqual([]); - fireEvent.click(retry); - await waitFor(() => - expect(syncRequests).toEqual([ - { - accountId: "account-1", - from: "2024-02-29", - screen: "income", - to: "2024-03-01", - }, - ]), - ); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); it("requests a multi-year period from the URL without resetting it", async () => { @@ -486,7 +466,7 @@ describe("AccountIncomePage", () => { ).toBeVisible(); }); - it("shows a recoverable realized error", async () => { + it("shows a quiet realized error", async () => { server.use( http.get( "*/api/v1/brokerage-accounts/account-1/income-analytics", @@ -495,8 +475,11 @@ describe("AccountIncomePage", () => { ); renderPage(); expect( - await screen.findByRole("button", { name: "Повторить" }), + await screen.findByText("Не удалось рассчитать пассивный доход", undefined, { + timeout: 4000, + }), ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); }); @@ -631,7 +614,7 @@ describe("income period and snapshot boundary", () => { expect(requests).toEqual(["?from=2026-09-01&to=2026-09-01"]); }); - it("keeps valid data and draft during refetch, warns on failure and retries", async () => { + it("keeps valid data and draft during refetch failure", async () => { respondWithRequestedPeriod([]); const { queryClient } = renderPage(); await screen.findByLabelText("Период"); @@ -653,51 +636,13 @@ describe("income period and snapshot boundary", () => { act(() => { void queryClient.invalidateQueries(); }); - expect( - await screen.findByRole("status", { name: "Обновляем пассивный доход" }), - ).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); expect(screen.getByLabelText("Период")).toHaveTextContent( "02.08.2026 – 27.08.2026", ); release(); - expect( - await screen.findByText(/Показан последний загруженный результат/), - ).toBeInTheDocument(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); - server.use( - http.post("*/api/v1/tbank-sync", () => - HttpResponse.json( - { - id: "income", - now: "2026-09-25T00:00:00Z", - scope: { accountId: "account-1", screen: "income" }, - }, - { status: 202 }, - ), - ), - http.get("*/api/v1/tbank-sync/:id", () => - - HttpResponse.json( - { - errorCode: null, - id: "income", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "succeeded", - }, - ), - ), - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(resource(analytics, { dataRevision: "2" })), - ), - ); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - await waitFor(() => - expect( - screen.queryByText(/Показан последний загруженный результат/), - ).not.toBeInTheDocument(), - ); + expect(screen.queryByText(/Показан последний загруженный результат/)).not.toBeInTheDocument(); expect(screen.getByLabelText("Период")).toHaveTextContent( "02.08.2026 – 27.08.2026", ); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 0d52a74..7d4bc42 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -5,7 +5,7 @@ import { useParams, useSearchParams } from "react-router"; import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; -import { isValidPeriod, moscowDate } from "@/shared/lib"; +import { isValidPeriod, moscowDate, useInitialAvailability } from "@/shared/lib"; import { type IncomeDisplayModel, @@ -27,19 +27,13 @@ type AccountIncomePageViewModel 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" }; @@ -69,7 +63,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { const [resetRevision, setResetRevision] = useState(0); const analytics = useIncomeAnalytics(accountId, period); - const sync = useScreenSync( + useScreenSync( { accountId, from: period.from, screen: "income", to: period.to }, [analytics], ); @@ -82,11 +76,20 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { form.reset(draftPeriod(period)); }, [accountId, form, period]); - if (analytics.isLoading || !analytics.data) { - return analytics.isError || analytics.meta?.sync.state === "failed" - ? { period, retry: sync.retry, state: "error" } + const availability = useInitialAvailability({ + hasContent: !!analytics.data, + identity: `${accountId}:${period.from}:${period.to}`, + isError: analytics.isError || analytics.meta?.sync.state === "failed", + isFetching: analytics.isFetching, + }); + + if (availability === "loading" && analytics.isLoading) + return { state: "loading" }; + + if (!analytics.data) + return availability === "unavailable" + ? { period, state: "error" } : { state: "loading" }; - } const display = toIncomeDisplayModel(analytics.data, analytics.meta); @@ -109,14 +112,9 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { display, form, formRevision: `${accountId}:${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", }; }; 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 e4603ea..bdaf3bd 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx @@ -1,6 +1,5 @@ import type { FC } from "react"; -import { Button } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; @@ -8,12 +7,11 @@ import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; type ErrorProps = Extract; -const AccountIncomePageError: FC = (props) => { +const AccountIncomePageError: FC = () => { 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 219c1f6..50db1bc 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -2,19 +2,13 @@ import type { FC } from "react"; import { Alert, - Button, - Group, - Loader, Stack, - Text, Title, } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { SourceSyncStatus } from "@/shared/ui"; - import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import classes from "../AccountIncomePage.module.css"; @@ -40,21 +34,14 @@ const AccountIncomePageLoadedSummary: FC = (props) => { }; const AccountIncomePageLoaded: FC = (props) => { - const { display, form, onReset, onSubmit, refreshError, refreshing, retry } - = props; + const { display, form, onReset, onSubmit } = props; const { t } = useTranslation(); return ( - - {t("income.title")} - - = (props) => { /> - {!!refreshing && ( - - - - {t("income.refreshing")} - - )} - - {!!refreshError && ( - - - - {t( - props.sourceError - ? "investment.syncFailed" - : "income.refreshError", - )} - - - - - - )} - {t("income.taxBoundary")} diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index ab4f146..6a0f9fe 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -346,7 +346,7 @@ describe("AccountOperationsPage", () => { , ); - await screen.findByRole("button", { name: "Повторить" }); + await screen.findByRole("button", { name: "Сбросить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); @@ -484,7 +484,9 @@ describe("AccountOperationsPage", () => { ); renderPage(); expect( - await screen.findByText("Не удалось загрузить журнал"), + await screen.findByText("Не удалось загрузить журнал", undefined, { + timeout: 4000, + }), ).toBeInTheDocument(); }); }); @@ -520,7 +522,7 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => }), ); renderPage("?from=2024-02-29&to=2024-03-01&categories=trade"); - const retry = await screen.findByRole("button", { name: "Повторить" }); + await screen.findByRole("button", { name: "Сбросить" }); expect(requested).toEqual([ { categories: "trade", @@ -530,19 +532,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => }, ]); expect(syncRequests).toEqual([]); - fireEvent.click(retry); - await waitFor(() => - expect(syncRequests).toEqual([ - { - accountId: "account-1", - categories: ["trade"], - from: "2024-02-29T00:00:00.000Z", - limit: 100, - screen: "operations", - to: "2024-03-01T23:59:59.999Z", - }, - ]), - ); }); it("does not label incomplete empty operations as a confirmed empty period", async () => { @@ -595,7 +584,6 @@ 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).toHaveLength(3)); assert.isDefined(searches[0]); assert.isDefined(searches[2]); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index 6b9f5fe..db29494 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -11,7 +11,7 @@ import { } from "@/entities/broker-operation"; import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; -import { isValidPeriod, useUrlPeriod } from "@/shared/lib"; +import { isValidPeriod, useInitialAvailability, useUrlPeriod } from "@/shared/lib"; const operationCategorySet = new Set(operationCategories); @@ -24,7 +24,6 @@ type AccountOperationsPageViewModel hasPrevious: boolean; historyLoading?: boolean; items: BrokerageOperation[]; - lastSuccessfulAt?: null | string; onApply: () => void; onCategoriesChange: (value: string[]) => void; onNext: () => void; @@ -32,13 +31,9 @@ type AccountOperationsPageViewModel onPrevious: () => void; onReset: () => void; partial?: boolean; - refreshError?: boolean; - refreshing?: boolean; - retry?: () => void; - sourceError?: boolean; state: "loaded"; } - | { onReset: () => void; retry: () => void; state: "error" } + | { onReset: () => void; state: "error" } | { state: "loading" }; const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { @@ -85,19 +80,28 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { history.reset(); } - const sync = useScreenSync( + useScreenSync( { accountId, screen: "operations", ...firstPageQuery }, [operations], ); - if (operations.isLoading) return { state: "loading" }; + const availability = useInitialAvailability({ + hasContent: !!operations.data, + identity: scope, + isError: operations.isError || operations.meta?.sync.state === "failed", + isFetching: operations.isFetching, + }); + + if (availability === "loading" && operations.isLoading) + return { state: "loading" }; + + if (history.isRecoveringInvalidCursor) + return { state: "loading" }; const resetQueryKey = brokerageOperationsQueryOptions(accountId, { limit: 100, }).queryKey; - const retry = () => history.retry(sync.retry); - const onReset = () => { setDraftPeriod([null, null]); setDraftCategories([]); @@ -106,16 +110,22 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { void queryClient.invalidateQueries({ queryKey: resetQueryKey }); }; + const isInvalidCursor = isApiErrorCode( + apiErrorCode(operations.error), + ApiErrorCode.InvalidOperationsCursor, + ); + if ( - (operations.isError || operations.meta?.sync.state === "failed") - && (!operations.data - || isApiErrorCode( - apiErrorCode(operations.error), - ApiErrorCode.InvalidOperationsCursor, - )) + (isInvalidCursor || !operations.data) + && (operations.isError || operations.meta?.sync.state === "failed") ) - return { onReset, retry, state: "error" }; - if (!operations.data) return { state: "loading" }; + return { onReset, state: "error" }; + + if (!operations.data) + return availability === "unavailable" + ? { onReset, state: "error" } + : { state: "loading" }; + const nextCursor = operations.data.nextCursor; const isDirty @@ -133,7 +143,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { operations.meta?.coverage?.historyState === "loading" || operations.meta?.coverage?.historyState === "partial", items: operations.data.items, - lastSuccessfulAt: operations.meta?.lastSuccessfulAt, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParameters({ @@ -155,10 +164,6 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => { onPrevious: history.onPrevious, onReset, partial: operations.meta?.availability !== "complete", - refreshError: operations.isError || sync.sourceError || sync.transportError, - refreshing: operations.isFetching || sync.refreshing, - retry, - sourceError: sync.sourceError, state: "loaded", }; }; 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 fd1fdf6..a45e4a0 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; -import { Button, Group } from "@mantine/core"; +import { Button } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; @@ -14,21 +14,15 @@ type AccountOperationsPageErrorProps = Extract< const AccountOperationsPageError: FC = ( props, ) => { - const { retry } = props; const { t } = useTranslation(); 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 504c644..18a69e7 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -5,7 +5,6 @@ import { Button, Group, MultiSelect, - Progress, Stack, Title, } from "@mantine/core"; @@ -14,7 +13,6 @@ import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; -import { SourceSyncStatus } from "@/shared/ui"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; @@ -62,34 +60,12 @@ const AccountOperationsPageLoaded: FC = ( return ( - - {t("ledger.title")} - - {!!props.partial && {t("investment.partial")}} {!!props.historyLoading && {t("investment.history")}} - {!!props.refreshing && } - - {!!props.refreshError && ( - - {t( - props.sourceError - ? "investment.syncFailed" - : "accounts.refreshError", - )} - - - - )} - Promise | undefined; state: "error"; to: string } - | { state: "empty" | "loading" | "partial"; to: string; - }; + } + | { state: "error"; to: string } + | { + state: "loaded"; + summary: IncomeSummary; + to: string; + } + ; type AccountPageRecentEvents = BrokerageEventsPreviewState; @@ -57,20 +55,16 @@ type PreviewResource = { function toIncomeSummary( accountId: string | undefined, income: PreviewResource, - retry: () => Promise, ): 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, state: "error", to }; + return { state: "error", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; return { - lastSuccessfulAt: income.meta?.lastSuccessfulAt, - refreshError: income.isError || income.meta?.sync.state === "failed", - stale: income.meta?.freshness === "stale", state: "loaded", summary: income.data, to, @@ -97,9 +91,6 @@ function toRecentEvents( return { items, - lastSuccessfulAt: events.meta?.lastSuccessfulAt, - refreshError: events.isError || events.meta?.sync.state === "failed", - stale: events.meta?.freshness === "stale", state: "loaded", to, }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 94afba2..de60722 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -1,7 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { - fireEvent, render, screen, waitFor, @@ -261,7 +260,7 @@ describe("AccountPage states", () => { it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])( "does not offer retry without cached data for %s", (code) => { - renderPage(); + renderPage(); expect( screen.queryByRole("button", { name: "Повторить" }), ).not.toBeInTheDocument(); @@ -274,12 +273,16 @@ describe("AccountPage states", () => { }, ); - it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])("allows retry for %s", (code) => { - const retry = vi.fn(); - renderPage(); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(retry).toHaveBeenCalledOnce(); - }); + it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])( + "shows neutral unavailable without retry for %s", + (code) => { + renderPage(); + expect(screen.getByRole("heading", { name: "Пока недоступно" })) + .toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + }, + ); it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { renderPage( @@ -405,11 +408,9 @@ describe("AccountPage states", () => { expect( screen.getByRole("heading", { level: 1, name: "Портфель" }), ).toBeInTheDocument(); - expect( - screen.getByText( - "T-Bank временно недоступен. Показан последний успешный снимок.", - ), - ).toBeInTheDocument(); + expect(screen.queryByText(/T-Bank временно недоступен/)) + .not.toBeInTheDocument(); + expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Акции" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Облигации" }), @@ -573,7 +574,7 @@ describe("AccountPage states", () => { ); expect( - screen.getByText("Не удалось загрузить последние события."), + screen.getByText("Пока недоступно"), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); @@ -742,13 +743,13 @@ describe("AccountPage states", () => { ); expect( - screen.getByText("Не удалось загрузить будущие события."), + screen.getByText("Данные появятся здесь, когда чтение восстановится."), ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); }); it.each([503])( - "preserves totals and UTC snapshot through refresh error %i, retry and success", + "preserves totals quietly through refresh error %i and success", async (status) => { const initialPortfolio: Portfolio = { @@ -821,40 +822,30 @@ describe("AccountPage states", () => { }); expect(structure).toHaveTextContent("10 ₽"); - const snapshotText = screen.getByText(/Данные получены/).textContent; - expect(snapshotText).toContain("10:00"); + expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); void queryClient.invalidateQueries({ queryKey: portfolioQueryOptions("account-1").queryKey, }); - expect( - await screen.findByText( - "Не удалось обновить портфель. Показан последний полученный снимок.", - ), - ).toBeInTheDocument(); - expect( - screen.getByText( - "T-Bank временно недоступен. Показан последний успешный снимок.", - ), - ).toBeInTheDocument(); + await waitFor(() => expect(requestCount).toBe(2)); + expect(screen.queryByText(/Не удалось обновить портфель/)) + .not.toBeInTheDocument(); + expect(screen.queryByText(/T-Bank временно недоступен/)) + .not.toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( 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, - ); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); + void queryClient.invalidateQueries({ + queryKey: portfolioQueryOptions("account-1").queryKey, + }); await waitFor(() => expect(requestCount).toBe(3)); expect(structure).toHaveTextContent("10 ₽"); expect( 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, - ); resolveRetry?.( HttpResponse.json( resource( @@ -875,7 +866,7 @@ describe("AccountPage states", () => { screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["-0,5 $", "2,50 €"]), ); - expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30"); + expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); expect(structure).not.toHaveTextContent("10 ₽"); expect(structure).toHaveTextContent("Нет положительной стоимости"); expect( @@ -916,13 +907,12 @@ describe("AccountPage states", () => { expect( screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); - expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00"); + expect(screen.queryByText(/Данные получены/)).not.toBeInTheDocument(); expect( screen.queryByRole("status", { name: "Загрузка" }), ).not.toBeInTheDocument(); - expect( - screen.getByRole("status", { name: "Обновляем портфель" }), - ).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Обновляем портфель" })) + .not.toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(resource(portfolio))); }); diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index edf4835..81b5417 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -18,10 +18,6 @@ const AccountPage: FC = () => { incomeSummary={viewModel.incomeSummary} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} - refreshError={viewModel.refreshError} - refreshing={viewModel.refreshing} - retry={viewModel.retry} - sourceError={viewModel.sourceError} /> ); @@ -35,10 +31,6 @@ const AccountPage: FC = () => { positionSections={viewModel.positionSections} recentEvents={viewModel.recentEvents} recentOperations={viewModel.recentOperations} - refreshError={viewModel.refreshError} - refreshing={viewModel.refreshing} - retry={viewModel.retry} - sourceError={viewModel.sourceError} /> ); }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index e0bea05..b6baf4e 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -1,7 +1,7 @@ import type { FC, PropsWithChildren } from "react"; import { MantineProvider } from "@mantine/core"; -import { act, renderHook, waitFor } from "@testing-library/react"; +import { renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -147,10 +147,10 @@ describe("useAccountPageViewModel", () => { HttpResponse.json(resource(portfolio)), ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => - HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }), + HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => - HttpResponse.json({ code: "MOEX_UNAVAILABLE" }, { status: 503 }), + HttpResponse.json({ code: "DEMO_FAILURE" }, { status: 400 }), ), ); @@ -208,7 +208,7 @@ it("keeps overview sections independent when their source data is incomplete", a ); }); -it("refreshes every overview section through one account descriptor and observational reads", async () => { +it("keeps overview sections loaded without exposing technical retry", async () => { configureTestAuth(); const sections: Record @@ -219,48 +219,17 @@ it("refreshes every overview section through one account descriptor and observat portfolio, }; - const observed: string[] = []; - const scopes: unknown[] = []; - let version = "1"; server.use( ...Object.entries(sections).map(([path, data]) => http.get( `*/api/v1/brokerage-accounts/:accountId/${path}`, - ({ request }) => { - if (new URL(request.url).searchParams.get("sync") === "observe") - observed.push(path); - + () => { return HttpResponse.json( - resource(data, { dataRevision: version, resourceKey: path }), + resource(data, { dataRevision: "1", resourceKey: path }), ); }, ), ), - http.post("*/api/v1/tbank-sync", async ({ request }) => { - scopes.push(await request.json()); - version = "2"; - - return HttpResponse.json( - { - id: "overview", - now: "2026-09-25T00:00:00Z", - scope: { accountId: "account-1", screen: "account" }, - }, - { status: 202 }, - ); - }), - http.get("*/api/v1/tbank-sync/:id", () => - - HttpResponse.json( - { - errorCode: null, - id: "overview", - now: "2026-09-25T00:00:00Z", - scope: null, - state: "succeeded", - }, - ), - ), ); const { result } = renderHook(() => useAccountPageViewModel(), { @@ -275,11 +244,5 @@ it("refreshes every overview section through one account descriptor and observat state: "loaded", }), ); - await act(async () => { - if (result.current.state === "loaded") await result.current.retry(); - }); - await waitFor(() => - expect(new Set(observed)).toEqual(new Set(Object.keys(sections))), - ); - expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]); + expect(result.current).not.toHaveProperty("retry"); }); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 77c0341..675e83f 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -13,7 +13,8 @@ import { import { useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; import { useScreenSync } from "@/features/tbank-connection"; -import { apiErrorCode } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { useInitialAvailability } from "@/shared/lib"; import { type AccountPagePositionSection, @@ -35,10 +36,6 @@ type AccountPageContent = { partial?: boolean; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; - refreshError: boolean; - refreshing: boolean; - retry: () => Promise | undefined; - sourceError?: boolean; }; type AccountPageEmptyViewModel = AccountPageContent & { @@ -54,9 +51,16 @@ type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageViewModel = | AccountPageEmptyViewModel | AccountPageLoadedViewModel - | { code?: string; retry: () => Promise | undefined; state: "error" } + | { code?: string; state: "error" } | { state: "loading" }; +function isPortfolioAccessFailure(code?: string) { + return isApiErrorCode(code, ApiErrorCode.Forbidden) + || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) + || isApiErrorCode(code, ApiErrorCode.NotFound) + || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); +} + function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); const portfolio = usePortfolio(accountId); @@ -69,7 +73,7 @@ function useAccountPageViewModel(): AccountPageViewModel { const income = useIncomeSummary(accountId); const accounts = useBrokerageAccounts(); - const sync = useScreenSync({ accountId, screen: "account" }, [ + useScreenSync({ accountId, screen: "account" }, [ accounts, portfolio, operations, @@ -77,32 +81,28 @@ function useAccountPageViewModel(): AccountPageViewModel { income, ]); - if (!portfolio.data) { - if (portfolio.isError || portfolio.meta?.sync.state === "failed") - return { - code: apiErrorCode(portfolio.error), - retry: sync.retry, - state: "error" as const, - }; + const code = apiErrorCode(portfolio.error); + const isAccessFailure = isPortfolioAccessFailure(code); - return { state: "loading" as const }; - } + const availability = useInitialAvailability({ + hasContent: !!portfolio.data, + identity: accountId ?? "account", + isError: portfolio.isError || portfolio.meta?.sync.state === "failed", + isFetching: portfolio.isFetching, + }); + + if (isAccessFailure) return { code, state: "error" as const }; + + if (!portfolio.data) + return { + state: availability === "unavailable" ? "error" : "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, () => income.refetch(), - ); - - 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, - }; + const incomeSummary = toIncomeSummary(accountId, income); if ( portfolio.meta?.availability === "complete" @@ -116,19 +116,20 @@ function useAccountPageViewModel(): AccountPageViewModel { recentEvents, recentOperations, state: "empty" as const, - ...refresh, }; + const isPartial = portfolio.meta?.availability === "partial"; + return { account, accountId, incomeSummary, + partial: isPartial, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, state: "loaded" as const, - ...refresh, }; } diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 1d3a28b..bf9c35d 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -7,7 +7,6 @@ import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; -import { InvestmentRefreshState } from "@/features/tbank-connection"; import type { AccountPageIncomeSummary, @@ -26,10 +25,6 @@ type AccountPageEmptyProps = { incomeSummary?: AccountPageIncomeSummary; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; - refreshError?: boolean; - refreshing?: boolean; - retry?: () => void; - sourceError?: boolean; }; const AccountPageEmpty: FC = (props) => { @@ -45,14 +40,6 @@ const AccountPageEmpty: FC = (props) => { - - void }; +type AccountPageErrorProps = { code?: string }; const AccountPageError: FC = (props) => { - const { code, retry } = props; + const { code } = props; const { t } = useTranslation(); if (isApiErrorCode(code, ApiErrorCode.NotFound)) return ( @@ -25,6 +25,7 @@ const AccountPageError: FC = (props) => { title={t("portfolio.accountUnavailableTitle")} /> ); + if ( isApiErrorCode(code, ApiErrorCode.TbankNotConnected) || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) @@ -41,22 +42,11 @@ const AccountPageError: FC = (props) => { title={t("portfolio.connectionErrorTitle")} /> ); - if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) - 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")} + text={t("states.unavailableText")} + title={t("states.unavailableTitle")} /> ); }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index c302c24..892b1c9 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -1,14 +1,12 @@ import type { FC } from "react"; -import { Accordion, Alert, Stack, Text } from "@mantine/core"; +import { Accordion, Alert, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { Portfolio } from "@/entities/portfolio"; import { BrokerageEventsPreview } from "@/entities/brokerage-event"; -import { InvestmentRefreshState } from "@/features/tbank-connection"; -import { formatSnapshotTime } from "@/shared/lib"; import type { AccountPagePositionSection } from "../../../model/positions"; import type { @@ -34,10 +32,6 @@ type AccountPageLoadedProps = { positionSections: AccountPagePositionSection[]; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; - refreshError?: boolean; - refreshing?: boolean; - retry?: () => void; - sourceError?: boolean; }; const AccountPageLoaded: FC = (props) => { @@ -55,27 +49,6 @@ const AccountPageLoaded: FC = (props) => { - - - {!!props.portfolio.isStale && {t("portfolio.staleWarning")}} - - - {t( - props.accountId === "all" - ? "portfolio.allUpdatedAt" - : "portfolio.updatedAt", - { - date: formatSnapshotTime(props.portfolio.lastSuccessfulAt), - }, - )} - - {props.partial ? {t("investment.partial")} : } diff --git a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx index 7a7afcf..56d91a3 100644 --- a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx +++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx @@ -8,30 +8,30 @@ import { Link } from "react-router"; import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; import { formatMoney } from "@/shared/lib"; -import { SourceSyncStatus } from "@/shared/ui"; type IncomeSummaryProps = | { - lastSuccessfulAt?: null | string; - refreshError?: boolean; - stale?: boolean; + state: "empty"; + to: string; + } + | { state: "error"; to: string } + | { state: "loaded"; summary: IncomeSummaryData; to: string; } - | { retry: () => void; state: "error"; to: string } - | { state: "empty"; to: string } - | { state: "loading"; to: string } - | { state: "partial"; to: string }; + | { + state: "loading"; + to: string; + } + | { + state: "partial"; + to: string; + }; const IncomeSummary: FC = (props) => { const { t } = useTranslation(); - const errorText - = props.state === "loaded" && props.refreshError - ? t("income.refreshError") - : null; - if (props.state === "loading" || props.state === "partial") return ( @@ -45,9 +45,6 @@ const IncomeSummary: FC = (props) => { if (props.state === "error") return ( {t("income.summaryRetry")} - } error text={t("income.errorText")} title={t("income.summaryError")} @@ -66,12 +63,6 @@ const IncomeSummary: FC = (props) => { return ( - - {props.summary.totalsByCurrency.map(total => ( {formatMoney(total.total)} diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx index 7e6acf1..84d5918 100644 --- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx @@ -1,12 +1,11 @@ import type { FC } from "react"; -import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; +import { Button, Skeleton, Stack, Text } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { LedgerEntryRow } from "@/entities/broker-operation"; -import { SourceSyncStatus } from "@/shared/ui"; import type { AccountPageRecentOperations } from "../../../model/previews"; @@ -18,28 +17,15 @@ const RecentOperations: FC = (props) => { const { t } = useTranslation(); const { operations } = props; - const errorText - = operations.state === "loaded" && operations.refreshError - ? t("accounts.refreshError") - : null; - return ( - {operations.state === "loaded" && ( - - )} - {operations.state === "loading" && ( {["first", "second", "third"].map(placeholder => )} )} - {operations.state === "error" && {t("ledger.recentError")}} + {operations.state === "error" && {t("states.unavailableTitle")}} {operations.state === "empty" && {t("ledger.recentEmpty")}} diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 6e69741..3d9431a 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 { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -58,10 +58,11 @@ describe("AccountsPage", () => { }), ); renderPage(createTestQueryClient()); - fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); - expect(await screen.findByText(account.name)).toBeVisible(); + expect(await screen.findByText(account.name, undefined, { + timeout: 4000, + })).toBeVisible(); - expect(modes).toEqual([null, "observe"]); + expect(modes).toEqual([null, null]); }); it.each([ ["loading", AccountsPageLoading, "status"], @@ -81,17 +82,16 @@ describe("AccountsPage", () => { ).toBeInTheDocument(); }); - it("routes connection errors to settings and keeps retry available", () => { - const retry = vi.fn(); + it("routes connection errors to settings without technical retry", () => { render( - + , ); - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - expect(retry).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); expect( screen.getByRole("link", { name: "Открыть настройки T-Bank" }), ).toHaveAttribute("href", "/settings"); @@ -123,14 +123,13 @@ describe("AccountsPage", () => { await waitFor(() => expect(requestCount).toBe(2)); expect(screen.getByText(account.name)).toBeInTheDocument(); - expect( - screen.getByRole("status", { name: "Обновляем данные" }), - ).toBeInTheDocument(); + expect(screen.queryByRole("status", { name: "Обновляем данные" })) + .not.toBeInTheDocument(); resolveRefresh?.(HttpResponse.json(resource([account]))); }); - it("keeps the cached account list and offers retry after a refresh error", async () => { + it("keeps the cached account list quietly after a refresh error", async () => { const requests: unknown[] = []; server.use( http.post("*/api/v1/tbank-sync", async ({ request }) => { @@ -161,17 +160,15 @@ describe("AccountsPage", () => { queryKey: brokerageAccountsQueryKey, }); - const warning = await screen.findByText( - "Не удалось обновить данные. Показаны последние полученные.", - ); - - expect(warning).toBeInTheDocument(); + await waitFor(() => expect(requestCount).toBe(2)); + expect( + screen.queryByText( + "Не удалось обновить данные. Показаны последние полученные.", + ), + ).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Повторить" })) + .not.toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Повторить" })); - await waitFor(() => expect(requestCount).toBe(3)); - await waitFor(() => expect(warning).not.toBeInTheDocument()); - expect(screen.getByText(account.name)).toBeVisible(); expect(requests).toEqual([]); }); @@ -194,11 +191,12 @@ describe("AccountsPage", () => { queryKey: brokerageAccountsQueryKey, }); + await waitFor(() => expect(requestCount).toBe(2)); expect( - await screen.findByText( + screen.queryByText( "Не удалось обновить данные. Показаны последние полученные.", ), - ).toBeInTheDocument(); + ).not.toBeInTheDocument(); expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument(); }); }); @@ -226,7 +224,7 @@ it.each(["idle", "failed"] as const)( .not.toBeInTheDocument(); if (state === "failed") // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. - expect(await screen.findByRole("button", { name: "Повторить" })) + expect(await screen.findByRole("heading", { name: "Пока недоступно" })) .toBeVisible(); else // eslint-disable-next-line vitest/no-conditional-expect -- Each fixed state has a different observable recovery contract. diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index ef6828f..241d6b6 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -10,25 +10,11 @@ const AccountsPage: FC = () => { const viewModel = useAccountsPageViewModel(); if (viewModel.state === "loading") return ; if (viewModel.state === "error") return ; - if (viewModel.state === "empty") - return ( - - ); + if (viewModel.state === "empty") return ; return ( ); }; diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index e7cd89e..9f60141 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -1,45 +1,54 @@ import { + type BrokerageAccountCard, brokerageAccountCards, useBrokerageAccounts, } from "@/entities/brokerage-account"; import { useScreenSync } from "@/features/tbank-connection"; -import { apiErrorCode } from "@/shared/api"; +import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; +import { useInitialAvailability } from "@/shared/lib"; -function useAccountsPageViewModel() { +type AccountsPageViewModel + = | { accounts: BrokerageAccountCard[]; state: "loaded" } + | { code?: string; state: "error" } + | { state: "empty" } + | { state: "loading" }; + +function isAccountsAccessFailure(code?: string) { + return isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) + || isApiErrorCode(code, ApiErrorCode.TbankNotConnected); +} + +function useAccountsPageViewModel(): AccountsPageViewModel { const accounts = useBrokerageAccounts(); - const sync = useScreenSync({ screen: "accounts" }, [accounts]); + useScreenSync({ screen: "accounts" }, [accounts]); - if ( - !accounts.data - || (accounts.meta?.availability === "partial" && accounts.data.length === 0) - ) { - if (!accounts.isError && accounts.meta?.sync.state !== "failed") - return { state: "loading" as const }; + const code = apiErrorCode(accounts.error); + const isAccessFailure = isAccountsAccessFailure(code); + const hasResult = !!accounts.data + && !(accounts.meta?.availability === "partial" && accounts.data.length === 0); + + const availability = useInitialAvailability({ + hasContent: hasResult, + identity: "accounts", + isError: accounts.isError || accounts.meta?.sync.state === "failed", + isFetching: accounts.isFetching, + }); + + if (isAccessFailure) return { code, state: "error" as const }; + + if (!hasResult || !accounts.data) return { - code: apiErrorCode(accounts.error), - retry: sync.retry, - state: "error" as const, - }; - } - - const refresh = { - lastSuccessfulAt: accounts.meta?.lastSuccessfulAt, - refreshError: accounts.isError || sync.transportError || sync.sourceError, - refreshing: accounts.isFetching || sync.refreshing, - retry: sync.retry, - sourceError: sync.sourceError, - }; + state: availability === "unavailable" ? "error" : "loading", + } as const; if (accounts.data.length === 0) return { - ...refresh, state: "empty" as const, }; return { accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), - ...refresh, state: "loaded" as const, }; } diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx index a7908cf..3680b6a 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx @@ -1,26 +1,16 @@ import type { FC } from "react"; -import { Stack } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; -import { - InvestmentRefreshState, - type InvestmentRefreshStateProps, -} from "@/features/tbank-connection"; - -const AccountsPageEmpty: FC = (props) => { +const AccountsPageEmpty: FC = () => { const { t } = useTranslation(); return ( - - - - - + ); }; diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx index 35132d7..7ad5c98 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx @@ -1,16 +1,16 @@ import type { FC } from "react"; -import { Button, Stack } from "@mantine/core"; +import { Button } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; -type AccountsPageErrorProps = { code?: string; retry?: () => void }; +type AccountsPageErrorProps = { code?: string }; const AccountsPageError: FC = (props) => { - const { code, retry } = props; + const { code } = props; const { t } = useTranslation(); const settingsAction = ( @@ -37,28 +37,11 @@ const AccountsPageError: FC = (props) => { title={t("accounts.invalidTokenTitle")} /> ); - if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) - return ( - - - - {settingsAction} - - )} - error - text={t("accounts.unavailableText")} - title={t("accounts.unavailableTitle")} - /> - ); return ( {t("accounts.retry")}} - error - text={t("states.errorText")} - title={t("states.errorTitle")} + text={t("states.unavailableText")} + title={t("states.unavailableTitle")} /> ); }; diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index b254cb6..4e74c79 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -8,17 +8,12 @@ import { type BrokerageAccountCard, BrokerageAccountList, } from "@/entities/brokerage-account"; -import { - InvestmentRefreshState, - type InvestmentRefreshStateProps, -} from "@/features/tbank-connection"; -type AccountsPageLoadedProps = InvestmentRefreshStateProps & { +type AccountsPageLoadedProps = { accounts: BrokerageAccountCard[]; }; const AccountsPageLoaded: FC = (props) => { - const { accounts, ...refresh } = props; const { t } = useTranslation(); return ( @@ -31,8 +26,6 @@ const AccountsPageLoaded: FC = (props) => { - - - + ); }; diff --git a/src/pages/bond/ui/BondPage/BondPage.test.tsx b/src/pages/bond/ui/BondPage/BondPage.test.tsx index aa57007..fada25a 100644 --- a/src/pages/bond/ui/BondPage/BondPage.test.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.test.tsx @@ -352,11 +352,7 @@ describe("bond page", () => { it("keeps market content and sold-position history when coupons fail", async () => { setup({ failCoupons: true, noPosition: true }); await screen.findByText("98,125 %"); - 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\"]"), - ).toHaveAttribute("aria-live", "polite"); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); expect( screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); @@ -364,38 +360,12 @@ describe("bond page", () => { screen.getByRole("heading", { name: "Моя история событий" }), ).toBeVisible(); }); - it("preserves cached quote on refresh failure", async () => { - const scopes: unknown[] = []; - server.use( - http.post("*/api/v1/tbank-sync", async ({ request }) => { - scopes.push(await request.json()); - - return HttpResponse.json( - { code: "TBANK_UNAVAILABLE" }, - { status: 503 }, - ); - }), - ); + it("preserves cached quote without manual refresh", async () => { const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` }); await screen.findByText("98,125 %"); controls.failQuote(); - fireEvent.click(screen.getByRole("button", { name: "Обновить" })); - 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\"]"), - ).toHaveAttribute("aria-live", "polite"); expect(screen.getByText("98,125 %")).toBeVisible(); - expect(scopes).toEqual([ - { - accountId: "all", - candlePeriod: "year", - categories: ["trade"], - instrumentUid: uid, - limit: 20, - screen: "bond", - }, - ]); + expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument(); }); it("offers search for an ambiguous or missing instrument without loading personal data", async () => { const { requests } = setup({ ambiguousTicker: true, path: "/bonds/SAME" }); diff --git a/src/pages/bond/ui/BondPage/BondPage.tsx b/src/pages/bond/ui/BondPage/BondPage.tsx index a870ea3..5f28d9f 100644 --- a/src/pages/bond/ui/BondPage/BondPage.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.tsx @@ -15,7 +15,6 @@ const BondPage: FC = () => { code={vm.code} kind="bond" loading={vm.bondState.loading} - retry={vm.bondState.retry} /> ); diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index e6debda..b727fdf 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -1,7 +1,6 @@ import type { FC } from "react"; import { - Alert, Badge, Group, Skeleton, @@ -137,8 +136,6 @@ const BondPageView: FC = (props) => { @@ -156,20 +153,8 @@ const BondPageView: FC = (props) => { )} - {!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && ( - - {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} - - )} - {quoteContent} - {!!(vm.quoteState.refreshing || vm.bondState.refreshing) && ( - - {t("accounts.refreshing")} - - )} - diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 6a0af5f..a3f2d8c 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -42,7 +42,7 @@ function useBondPageVm() { const { operations } = history; - const sync = useScreenSync( + useScreenSync( { accountId: "all", candlePeriod: period, @@ -56,29 +56,26 @@ function useBondPageVm() { return { bond: bond.data, - bondState: section(bond, uid ? sync.retry : () => bond.refetch()), + bondState: section(bond), candles: candles.data, - candlesState: section(candles, sync.retry), + candlesState: section(candles), canonicalTo: bond.data?.slug && bond.data.slug !== bondId ? `/bonds/${encodeURIComponent(bond.data.slug)}${search}` : null, code: apiErrorCode(bond.error), coupons: coupons.data, - couponsState: section(coupons, sync.retry), + couponsState: section(coupons), eventRows: toInstrumentEventRows(operations.data?.items, bond.data), filter, hasPrevious: history.hasPrevious, logoSrc: instrumentLogoUrl(bond.data?.logoName), metrics: metrics.data, - metricsState: section(metrics, sync.retry), + metricsState: section(metrics), onNext: history.onNext, onPrevious: history.onPrevious, operations: operations.data, - operationsState: { - ...section(operations, sync.retry), - retry: () => history.retry(sync.retry), - }, + operationsState: section(operations), pastCoupons: coupons.data?.coupons .filter(event => event.timing !== "upcoming") @@ -87,12 +84,9 @@ function useBondPageVm() { ) ?? [], period, position: position.data, - positionState: section(position, sync.retry), + positionState: section(position), quote: quote.data, - quoteState: section(quote, sync.retry), - refresh: sync.refresh, - refreshError: sync.transportError || sync.sourceError, - refreshing: sync.refreshing, + quoteState: section(quote), setFilter, setPeriod, upcoming: diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts index db55a65..d3a9297 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts @@ -10,12 +10,14 @@ function useBondsPageVm() { const search = useInstrumentSearch(MIN_BOND_SEARCH_LENGTH); const { canSearch, debounced, isWaiting, query } = search; const result = useBondSearch(debounced); - const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]); + useScreenSync({ query: debounced, screen: "bonds" }, [result]); return { canSearch, code: apiErrorCode(result.error), - error: result.isError || sync.sourceError || sync.transportError, + error: + !result.data + && (result.isError || result.meta?.sync.state === "failed"), items: result.data?.map(bond => ({ ...bond, @@ -35,10 +37,6 @@ function useBondsPageVm() { debounced.length >= MIN_BOND_SEARCH_LENGTH && !isWaiting && Boolean(result.data), - refreshing: sync.refreshing, - retry: () => { - void sync.retry(); - }, }; } diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index 8140403..467d35a 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -256,21 +256,19 @@ describe("CardPage", () => { }), ); fireEvent.click(screen.getByRole("button", { name: "Далее" })); - await waitFor(() => - expect(screen.getByRole("button", { name: "Далее" })).toBeDisabled(), - ); + await waitFor(() => { + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); expect(screen.getByText("Перевод на «Запасная»")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Изменить" })).toBeDisabled(); - expect( - screen.queryByLabelText("Загрузка операций"), - ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Загрузка операций")).not.toBeInTheDocument(); expect(scroll).not.toHaveBeenCalled(); measure.mockReturnValue({ top: 200 } as DOMRect); release(); await screen.findByText(/Новая страница/); - // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. - const list = screen.getByText(/Новая страница/).closest("[aria-busy]"); - expect(list?.parentElement?.style.minHeight).toBe(""); + const newPageText = screen.getByText(/Новая страница/); + expect(newPageText).toBeInTheDocument(); + expect(scroll).toHaveBeenCalledExactlyOnceWith({ behavior: "instant", top: -400, @@ -317,7 +315,9 @@ describe("CardPage", () => { it("keeps card and journal when summary fails", async () => { setup({ summaryError: true }); await screen.findByRole("heading", { name: "Повседневная" }); - await screen.findByRole("alert"); + expect( + screen.queryByRole("alert"), + ).not.toBeInTheDocument(); expect( await screen.findByText("Перевод на «Запасная»"), ).toBeInTheDocument(); @@ -331,7 +331,9 @@ describe("CardPage", () => { http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()), ); await client.invalidateQueries(); - await screen.findAllByRole("alert"); + await waitFor(() => { + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); expect( screen.getByRole("heading", { name: "Повседневная" }), ).toBeInTheDocument(); @@ -401,9 +403,9 @@ describe("CardPage", () => { it("recovers the prerequisite card list while preserving the visible balance and journal", async () => { setup({ cardsError: true }); await screen.findByText("Перевод на «Запасная»"); - expect(await screen.findByRole("alert")).toHaveTextContent( - "Не удалось загрузить данные карт.", - ); + expect( + screen.queryByRole("alert"), + ).not.toBeInTheDocument(); expect( screen.getByRole("button", { name: "Добавить операцию" }), ).toBeDisabled(); @@ -412,11 +414,6 @@ describe("CardPage", () => { HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), ), ); - fireEvent.click( - within(screen.getByRole("alert")).getByRole("button", { - name: "Повторить", - }), - ); await waitFor(() => expect( screen.getByRole("button", { name: "Добавить операцию" }), diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx index e839f64..4a499d9 100644 --- a/src/pages/card/ui/CardPage/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage/CardPage.view.tsx @@ -75,7 +75,6 @@ const CardPageView: FC = (props) => { {t("bank.back")} - {!vm.notFound && } )} error @@ -125,27 +124,16 @@ const CardPageView: FC = (props) => { {!!vm.archiveError && ( - + )} {card.archivedAt && {t("bank.archiveHint")}} - {vm.cardRefreshError && ( - - {t("bank.refreshError")} - - - - )} - - {vm.cardRefreshing && {t("bank.refreshing")}} - {vm.cardsFeedback && ( )} @@ -187,7 +175,6 @@ const CardPageView: FC = (props) => { = (props) => { - {vm.history.error && ( - - {t(vm.rows ? "bank.refreshError" : "bank.error")} - - - - )} + {vm.history.error && !vm.rows && {t("bank.error")}} {vm.history.page !== undefined && vm.pageSize !== "all" && ( = (props) => { ref={paginationRef} > void history.refetch(), }, notFound: isApiErrorCode( apiErrorCode(card.error), @@ -128,8 +126,6 @@ function useCardPageVm() { page, pageSize, period, - retryCard: () => void card.refetch(), - retryCards: () => void cards.refetch(), rows, setPage: (value: number) => setParameters({ ...period, limit: pageSize, page: String(value) }), @@ -146,7 +142,6 @@ function useCardPageVm() { error: summary.isError, fetching: summary.isFetching, loading: summary.isLoading, - retry: () => void summary.refetch(), }, toggleArchive: () => { if (!card.data) return; diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts index d456231..dea3507 100644 --- a/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts +++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.vm.ts @@ -1,3 +1,4 @@ +import { notifications } from "@mantine/notifications"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -44,10 +45,16 @@ function useDeleteOperationVm(props: DeleteOperationProps) { setError(undefined); try { + const actionId = crypto.randomUUID(); await operationDeletion.mutateAsync({ id: record.id, version: record.version, }); + + notifications.show({ + id: "bank-operation-deleted-" + actionId, + message: t("bank.operationDeleted"), + }); setOpened(false); } catch (error_) { diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index e6d5ae1..bae39b7 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod"; +import { notifications } from "@mantine/notifications"; import { useState } from "react"; import { useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; @@ -112,6 +113,8 @@ const useOperationFormVm = (props: OperationFormProps) => { description: values.description, }; + const actionId = crypto.randomUUID(); + if (props.operation) await updateOperation.mutateAsync({ body: { ...body, version }, @@ -122,6 +125,11 @@ const useOperationFormVm = (props: OperationFormProps) => { id: intent, ...body, }); + + notifications.show({ + id: "bank-operation-saved-" + actionId, + message: t("bank.operationSaved"), + }); setOpened(false); } catch (error_) { diff --git a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx index bc6793d..8856550 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx @@ -198,26 +198,26 @@ describe("CardsPage", () => { it("shows retry after failure instead of presenting an empty list", async () => { renderCards(true); // Mount starts asynchronously; this handler owns the request. - expect(await screen.findByRole("alert")).toHaveTextContent( - "Не удалось загрузить данные карт.", - ); + expect( + screen.queryByRole("alert"), + ).not.toBeInTheDocument(); expect( screen.queryByRole("heading", { name: "Добавьте первую карту" }), ).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.json([]))); - fireEvent.click( - within(screen.getByRole("alert")).getByRole("button", { - name: "Повторить", - }), - ); - await screen.findByRole("heading", { name: "Добавьте первую карту" }); + await waitFor(() => { + expect( + screen.queryByRole("alert"), + ).not.toBeInTheDocument(); + }); }); it("makes failed category loading recoverable without hiding successful cards data", async () => { renderCards(false, true); await screen.findByRole("heading", { name: "Добавьте первую карту" }); - expect(await screen.findByRole("alert")).toHaveTextContent( - "Не удалось загрузить данные карт.", - ); + expect( + screen.queryByRole("alert"), + ).not.toBeInTheDocument(); server.use( http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([ @@ -225,12 +225,11 @@ describe("CardsPage", () => { ]), ), ); - fireEvent.click( - within(screen.getByRole("alert")).getByRole("button", { - name: "Повторить", - }), - ); - await screen.findByRole("button", { name: "Категории" }); + await waitFor(() => { + expect( + screen.getByRole("button", { name: "Категории" }), + ).toBeInTheDocument(); + }); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); }); diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx index 6072698..1ad94dd 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx @@ -9,7 +9,6 @@ import { Skeleton, Stack, Switch, - Text, Title, } from "@mantine/core"; import { AddIcon, CategoriesIcon } from "@wealthledger/icons"; @@ -19,7 +18,6 @@ import { Link } from "react-router"; import { BankCardFace, - BankFeedback, BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; @@ -107,18 +105,6 @@ const CardsPageView: FC = (props) => { - {vm.cards.isError && ( - - {t(vm.items ? "bank.refreshError" : "bank.error")} - - - - )} - - {vm.cards.data && vm.cards.isFetching && {t("bank.refreshing")}} - @@ -140,17 +126,7 @@ const CardsPageView: FC = (props) => { error={vm.summary.isError} fetching={vm.summary.isFetching} loading={vm.summary.isLoading} - retry={vm.retrySummary} /> - - {vm.categories.isError && ( - - )} ); }; diff --git a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts index b7d3e05..20b00aa 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts +++ b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts @@ -39,9 +39,6 @@ function useCardsPageVm() { setArchive(event.currentTarget.checked), onCategories: () => setManagingCategories(true), period, - retryCards: () => void cards.refetch(), - retryCategories: () => void categories.refetch(), - retrySummary: () => void summary.refetch(), setPeriod: (value: [null | string, null | string]) => { if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] }); }, diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index 2b90355..e0d5c46 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -396,37 +396,15 @@ describe("stock page", () => { screen.queryByRole("heading", { name: "Моя позиция" }), ).not.toBeInTheDocument(); expect(screen.getByRole("heading", { name: "График цены" })).toBeVisible(); - 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\"]"), - ).toHaveAttribute("aria-live", "polite"); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); - it("recovers an initially missing company section without reloading successful sections", async () => { + it("keeps successful sections visible when company data is initially missing", async () => { const { requests } = setup({ failCompany: true }); - const retry = await screen.findByRole("button", { name: "Повторить" }); await screen.findByText("Зачисление дивидендов"); - const modes: (null | string)[] = []; - 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: [], - observedAt: quote.observedAt, - unavailable: [], - }), - ); - }), - ); const previousRequests = requests.length; - fireEvent.click(retry); - expect(await screen.findByText("Восстановленное описание")).toBeVisible(); expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); expect(requests).toHaveLength(previousRequests); - expect(modes).toEqual(["observe"]); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); }); it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { const { requests } = setup(); @@ -469,75 +447,28 @@ describe("stock page", () => { ); }); it.each([ - [ - "INVALID_OPERATIONS_CURSOR", - null, - null, - ], - ["TBANK_UNAVAILABLE", "next", "observe"], + "INVALID_OPERATIONS_CURSOR", + "TBANK_UNAVAILABLE", ])( - "recovers history after %s with fresh data and the correct cursor", - async (code, cursor, sync) => { + "keeps history quiet after %s without technical retry", + async (code) => { 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) - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(); - // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. - else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Моя история событий" })).toBeVisible(); }, ); - it("keeps the last quote on a refresh failure", async () => { - const scopes: unknown[] = []; - server.use( - http.post("*/api/v1/tbank-sync", async ({ request }) => { - scopes.push(await request.json()); - - return HttpResponse.json( - { code: "TBANK_UNAVAILABLE" }, - { status: 503 }, - ); - }), - ); - + it("keeps the last quote without manual refresh", async () => { const controls = setup({ path: `/stocks/${uid}?period=year&events=trades`, }); await screen.findByText("300 ₽"); controls.failQuote(); - fireEvent.click(screen.getByRole("button", { name: "Обновить" })); - 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\"]"), - ).toHaveAttribute("aria-live", "polite"); expect(screen.getByText("300 ₽")).toBeVisible(); - expect(scopes).toEqual([ - { - accountId: "all", - candlePeriod: "year", - categories: ["trade"], - instrumentUid: uid, - limit: 20, - screen: "stock", - }, - ]); + expect(screen.queryByRole("button", { name: "Обновить" })).not.toBeInTheDocument(); }); it("offers recovery for an unavailable direct link", async () => { setup({ failStock: true }); diff --git a/src/pages/stock/ui/StockPage/StockPage.tsx b/src/pages/stock/ui/StockPage/StockPage.tsx index a9780a7..baae627 100644 --- a/src/pages/stock/ui/StockPage/StockPage.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.tsx @@ -15,7 +15,6 @@ const StockPage: FC = () => { code={vm.code} kind="stock" loading={vm.stockState.loading} - retry={vm.stockState.retry} /> ); diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 42ba5af..28d963b 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -1,9 +1,6 @@ import type { FC } from "react"; -import { - Alert, - Text, -} from "@mantine/core"; +import { Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { @@ -42,8 +39,6 @@ const StockPageView: FC = (props) => { @@ -53,12 +48,6 @@ const StockPageView: FC = (props) => { )} - {!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && ( - - {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} - - )} - history.retry(sync.retry), - }, + operationsState: section(operations), pastDividends: dividends.data?.dividends .filter(event => event.timing !== "upcoming") @@ -85,16 +82,13 @@ function useStockPageVm() { ) ?? [], period, position: position.data, - positionState: section(position, sync.retry), + positionState: section(position), quote: quote.data, - quoteState: section(quote, sync.retry), - refresh: sync.refresh, - refreshError: sync.transportError || sync.sourceError, - refreshing: sync.refreshing, + quoteState: section(quote), setFilter, setPeriod, stock: stock.data, - stockState: section(stock, uid ? sync.retry : () => stock.refetch()), + stockState: section(stock), upcoming: dividends.data?.dividends .filter(event => event.timing === "upcoming") diff --git a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx index df67069..500a725 100644 --- a/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockCompany.view.tsx @@ -1,12 +1,6 @@ import type { FC } from "react"; -import { - Alert, - Button, - Group, - Stack, - Text, -} from "@mantine/core"; +import { Alert, Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { @@ -80,17 +74,7 @@ const StockCompany: FC = (props) => { {props.company.unavailable.length > 0 && ( - - {t("stock.partialCompany")} - - - + {t("stock.partialCompany")} )} diff --git a/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx index 5c9a7b7..0b2b53a 100644 --- a/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockQuote.view.tsx @@ -82,12 +82,6 @@ const StockQuote: FC = (props) => { {t("stock.partialQuote")} )} - - {!!props.quoteState.refreshing && ( - - {t("accounts.refreshing")} - - )} ); } diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index a35e22e..957bd83 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -10,12 +10,14 @@ function useStocksPageVm() { const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); const { canSearch, debounced, isWaiting, query } = search; const result = useStockSearch(debounced); - const sync = useScreenSync({ query: debounced, screen: "stocks" }, [result]); + useScreenSync({ query: debounced, screen: "stocks" }, [result]); return { canSearch, code: apiErrorCode(result.error), - error: result.isError || sync.sourceError || sync.transportError, + error: + !result.data + && (result.isError || result.meta?.sync.state === "failed"), items: result.data?.map(stock => ({ ...stock, @@ -35,10 +37,6 @@ function useStocksPageVm() { debounced.length >= MIN_STOCK_SEARCH_LENGTH && !isWaiting && Boolean(result.data), - refreshing: sync.refreshing, - retry: () => { - void sync.retry(); - }, }; } diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index e588b64..db15880 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -1,12 +1,26 @@ import { http, HttpResponse } from "msw"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { apiClient, configureAuthClient } from "./client"; +async function advanceReadDeadlineRecovery() { + await vi.advanceTimersByTimeAsync(15_000); + await vi.advanceTimersByTimeAsync(1000); +} + +async function advanceTransportRecovery() { + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(2000); +} + describe("apiClient authentication", () => { + afterEach(() => { + vi.useRealTimers(); + }); + it("refreshes once and retries an unauthorized protected request", async () => { let token = "expired"; @@ -109,3 +123,186 @@ describe("apiClient authentication", () => { await response; }); }); + +describe("apiClient transient read recovery", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("recovers a protected GET after transient server failures", async () => { + vi.useFakeTimers(); + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + + return requests < 3 + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource([{ id: "account-1" }])); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + + const response = apiClient.GET("/api/v1/brokerage-accounts"); + await advanceTransportRecovery(); + + await expect(response).resolves.toMatchObject({ + data: { data: [{ id: "account-1" }] }, + }); + expect(requests).toBe(3); + }); + + it("recovers a protected GET after network failure", async () => { + vi.useFakeTimers(); + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + + return requests === 1 + ? HttpResponse.error() + : HttpResponse.json(resource([{ id: "account-1" }])); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + + const response = apiClient.GET("/api/v1/brokerage-accounts"); + await advanceTransportRecovery(); + + await expect(response).resolves.toMatchObject({ + data: { data: [{ id: "account-1" }] }, + }); + expect(requests).toBe(2); + }); + + it("times out a stalled response body before retrying", async () => { + vi.useFakeTimers(); + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + + return requests === 1 + ? new Response(new ReadableStream()) + : HttpResponse.json(resource([{ id: "account-1" }])); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + + const response = apiClient.GET("/api/v1/brokerage-accounts"); + await advanceReadDeadlineRecovery(); + + await expect(response).resolves.toMatchObject({ + data: { data: [{ id: "account-1" }] }, + }); + expect(requests).toBe(2); + }); + + it("does not retry rate limited reads before the server delay", async () => { + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + + return HttpResponse.json( + { code: "RATE_LIMITED" }, + { headers: { "Retry-After": "60" }, status: 429 }, + ); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + + await apiClient.GET("/api/v1/brokerage-accounts"); + + expect(requests).toBe(1); + }); + + it("does not retry malformed successful responses as transport failures", async () => { + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + + return new Response("not json", { + headers: { "Content-Type": "application/json" }, + status: 200, + }); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + + await expect( + apiClient.GET("/api/v1/brokerage-accounts"), + ).rejects.toThrow(SyntaxError); + + expect(requests).toBe(1); + }); + + it.each([401, 403, 404, 409])( + "does not retry permanent HTTP %s responses", + async (status) => { + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + + return HttpResponse.json({ code: "FORBIDDEN" }, { status }); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(false), + }); + + await apiClient.GET("/api/v1/brokerage-accounts"); + + expect(requests).toBe(1); + }, + ); + + it("does not retry mutations", async () => { + let requests = 0; + server.use( + http.put("*/api/v1/tbank-connection", () => { + requests += 1; + + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + + await apiClient.PUT("/api/v1/tbank-connection", { + body: { token: "token" }, + }); + + expect(requests).toBe(1); + }); +}); diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index ea8d312..d9dbe75 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -6,6 +6,17 @@ import type { paths } from "./generated/schema"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; const HTTP_UNAUTHORIZED = 401; +const HTTP_REQUEST_TIMEOUT = 408; +const HTTP_TOO_MANY_REQUESTS = 429; +const HTTP_INTERNAL_SERVER_ERROR = 500; +const HTTP_BAD_GATEWAY = 502; +const HTTP_SERVICE_UNAVAILABLE = 503; +const HTTP_GATEWAY_TIMEOUT = 504; +const GET_DEADLINE_MS = 15_000; +const FIRST_RETRY_DELAY_MS = 1000; +const SECOND_RETRY_DELAY_MS = 2000; +const RETRY_DELAYS_MS = [FIRST_RETRY_DELAY_MS, SECOND_RETRY_DELAY_MS] as const; +const SECONDS_TO_MS = 1000; const configuredBaseUrl: unknown = import.meta.env.VITE_API_BASE_URL; @@ -23,12 +34,22 @@ type AuthHandlers = { refresh: () => Promise; }; +type ReadAttempt = { + problem?: unknown; + reason: "abort" | "transient"; + response?: Response; +}; + const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = { hasSession: false, }; const authSessionListeners = new Set<() => void>(); +function abortError() { + return new DOMException("Request aborted", "AbortError"); +} + async function authenticatedFetch(request: Request): Promise { const isAuthRequest = new URL(request.url).pathname.startsWith( "/api/v1/auth/", @@ -56,7 +77,7 @@ async function authenticatedFetch(request: Request): Promise { = token && !isAuthRequest ? withToken(request, token) : request; const retryRequest = authorized.clone(); - const response = await fetch(authorized); + const response = await fetchAppRead(authorized, isAuthRequest); if (!isAuthRequest && !isCurrent()) throw new DOMException("Session changed", "AbortError"); if ( @@ -87,7 +108,12 @@ async function authenticatedFetch(request: Request): Promise { if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); const refreshedToken = sessionState.handlers.accessToken(); if (!refreshedToken) return response; - const retried = await fetch(withToken(retryRequest, refreshedToken)); + + const retried = await fetchAppRead( + withToken(retryRequest, refreshedToken), + isAuthRequest, + ); + if (!isCurrent()) throw new DOMException("Session changed", "AbortError"); return retried; @@ -98,10 +124,138 @@ function configureAuthClient(handlers?: AuthHandlers) { setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken())); } +async function fetchAppRead( + request: Request, + isAuthRequest: boolean, +): Promise { + if (isAuthRequest || request.method !== "GET") return fetch(request); + + const retryableRequest = request.clone(); + + for (const [retryIndex, delayMs] of RETRY_DELAYS_MS.entries()) { + const attempt = await readAttempt( + retryIndex === 0 ? request : retryableRequest.clone(), + ); + + if (attempt.reason === "abort") throw abortError(); + const response = attempt.response; + if (response && !isTransientReadFailure(response, attempt.problem)) + return response; + await waitForRetry(delayMs, request.signal); + } + + const finalAttempt = await readAttempt(retryableRequest.clone()); + if (finalAttempt.reason === "abort") throw abortError(); + if (!finalAttempt.response) throw new TypeError("Network request failed"); + + return finalAttempt.response; +} + function hasAuthenticatedSession() { return sessionState.hasSession; } +function isNetworkFailure(error: unknown): boolean { + return error instanceof TypeError; +} + +function isPermanentCode(code?: string): boolean { + return [ + ApiErrorCode.Forbidden, + ApiErrorCode.InvalidTbankToken, + ApiErrorCode.NotFound, + ApiErrorCode.TbankNotConnected, + ApiErrorCode.Unauthorized, + ].some(permanent => isApiErrorCode(code, permanent)); +} + +function isTransientReadFailure( + response: Response | undefined, + problem: unknown, +): boolean { + if (!response) return true; + const code = apiErrorCode(problem); + if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable)) return true; + if (isPermanentCode(code)) return false; + + const transientStatuses = [ + HTTP_BAD_GATEWAY, + HTTP_GATEWAY_TIMEOUT, + HTTP_INTERNAL_SERVER_ERROR, + HTTP_REQUEST_TIMEOUT, + HTTP_SERVICE_UNAVAILABLE, + ]; + + return transientStatuses.includes(response.status) + || ( + response.status === HTTP_TOO_MANY_REQUESTS + && retryAfterMs(response) === 0 + ); +} + +function parseJson(body: string): unknown { + try { + return JSON.parse(body); + } + catch { + return undefined; + } +} + +async function readAttempt(request: Request): Promise { + const controller = new AbortController(); + const failure: { reason?: "abort" | "timeout" } = {}; + + const { promise: deadline, reject: rejectDeadline } + = Promise.withResolvers(); + + const timeout = setTimeout(() => { + failure.reason = "timeout"; + controller.abort(); + rejectDeadline(abortError()); + }, GET_DEADLINE_MS); + + const abort = () => { + failure.reason = "abort"; + controller.abort(); + rejectDeadline(abortError()); + }; + + request.signal.addEventListener("abort", abort, { once: true }); + const timedRequest = new Request(request, { signal: controller.signal }); + + try { + const response = await Promise.race([fetch(timedRequest), deadline]); + const body = await Promise.race([response.text(), deadline]); + const problem = parseJson(body); + + return { + problem, + reason: "transient", + response: new Response(body, response), + }; + } + catch (error) { + if (failure.reason === "abort") return { reason: "abort" }; + if (failure.reason === "timeout" || isNetworkFailure(error)) + return { reason: "transient" }; + + throw error; + } + finally { + clearTimeout(timeout); + request.signal.removeEventListener("abort", abort); + } +} + +function retryAfterMs(response: Response): number | undefined { + const header = response.headers.get("Retry-After"); + if (!header) return undefined; + const seconds = Number(header); + + return Number.isFinite(seconds) ? seconds * SECONDS_TO_MS : undefined; +} + function setAuthenticatedSession(hasSession: boolean) { if (hasSession === sessionState.hasSession) return; sessionState.hasSession = hasSession; @@ -114,6 +268,19 @@ function subscribeAuthenticatedSession(listener: () => void) { return () => authSessionListeners.delete(listener); } +function waitForRetry(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, ms); + + const abort = () => { + clearTimeout(timeout); + reject(abortError()); + }; + + signal.addEventListener("abort", abort, { once: true }); + }); +} + const apiClient = createClient({ baseUrl: apiBaseUrl, credentials: "include", diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index e9c4d04..0abfd80 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -76,9 +76,11 @@ const ru = { bankName: "Банк (необязательно)", cancel: "Отмена", card: "Карта", + cardSaved: "Карта сохранена", categories: "Категории", category: "Категория", categoryName: "Название категории", + categorySaved: "Категория сохранена", close: "Закрыть", date: "Дата операции", defaultCategory: "По умолчанию", @@ -153,7 +155,9 @@ const ru = { openingHint: "Остаток до операций с выбранной даты. Валюта — рубли (RUB).", operationCard: "Карта операции", + operationDeleted: "Операция удалена", operations: "Операции", + operationSaved: "Операция сохранена", page: "Страница {{page}}", pageSize: "Операций на странице", pagination: "Страницы операций", @@ -516,6 +520,8 @@ const ru = { errorTitle: "Что-то пошло не так", loadingText: "Пожалуйста, подождите.", loadingTitle: "Загрузка", + unavailableText: "Данные появятся здесь, когда чтение восстановится.", + unavailableTitle: "Пока недоступно", }, stock: { aboutCompany: "О компании", @@ -696,6 +702,7 @@ const ru = { "Не удалось загрузить статус подключения. Обновите страницу.", title: "T-Bank Invest", token: "Token T-Bank", + tokenReplaced: "Подключение обновлено", tokenRequired: "Введите token", unavailable: "T-Bank временно недоступен. Повторите попытку позже.", }, diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 5561391..9b45661 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -8,4 +8,8 @@ export { formatSnapshotTime, instrumentLogoUrl, } from "./display"; +export { + type InitialAvailability, + useInitialAvailability, +} from "./initial-availability"; export { useUrlPeriod } from "./useUrlPeriod"; diff --git a/src/shared/lib/initial-availability.test.tsx b/src/shared/lib/initial-availability.test.tsx new file mode 100644 index 0000000..d4ca70a --- /dev/null +++ b/src/shared/lib/initial-availability.test.tsx @@ -0,0 +1,107 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useInitialAvailability } from "./initial-availability"; + +describe("useInitialAvailability", () => { + it("keeps loading until content, error, or the initial wait budget", async () => { + vi.useFakeTimers(); + + try { + const { rerender, result } = renderHook( + props => useInitialAvailability(props), + { + initialProps: { + hasContent: false, + identity: "account-a", + isError: false, + isFetching: true, + timeoutMs: 100, + }, + }, + ); + + expect(result.current).toBe("loading"); + await act(() => vi.advanceTimersByTimeAsync(99)); + expect(result.current).toBe("loading"); + await act(() => vi.advanceTimersByTimeAsync(1)); + expect(result.current).toBe("unavailable"); + + rerender({ + hasContent: true, + identity: "account-a", + isError: false, + isFetching: false, + timeoutMs: 100, + }); + expect(result.current).toBe("ready"); + } + finally { + vi.useRealTimers(); + } + }); + + it("does not flash loading during retry for the same identity", async () => { + vi.useFakeTimers(); + + try { + const { rerender, result } = renderHook( + props => useInitialAvailability(props), + { + initialProps: { + hasContent: false, + identity: "account-a", + isError: true, + isFetching: false, + }, + }, + ); + + await act(() => vi.runOnlyPendingTimersAsync()); + expect(result.current).toBe("unavailable"); + rerender({ + hasContent: false, + identity: "account-a", + isError: false, + isFetching: true, + }); + + expect(result.current).toBe("unavailable"); + } + finally { + vi.useRealTimers(); + } + }); + + it("resets loading only when identity changes", async () => { + vi.useFakeTimers(); + + try { + const { rerender, result } = renderHook( + props => useInitialAvailability(props), + { + initialProps: { + hasContent: false, + identity: "account-a", + isError: true, + isFetching: false, + }, + }, + ); + + await act(() => vi.runOnlyPendingTimersAsync()); + expect(result.current).toBe("unavailable"); + rerender({ + hasContent: false, + identity: "account-b", + isError: false, + isFetching: true, + }); + + expect(result.current).toBe("loading"); + } + finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/shared/lib/initial-availability.ts b/src/shared/lib/initial-availability.ts new file mode 100644 index 0000000..4366b8c --- /dev/null +++ b/src/shared/lib/initial-availability.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; + +const INITIAL_WAIT_BUDGET_MS = 10_000; + +type InitialAvailability + = | "loading" + | "ready" + | "unavailable"; + +type InitialAvailabilityInput = { + hasContent: boolean; + identity: string; + isError: boolean; + isFetching: boolean; + timeoutMs?: number; +}; + +function useInitialAvailability( + input: InitialAvailabilityInput, +): InitialAvailability { + const timeoutMs = input.timeoutMs ?? INITIAL_WAIT_BUDGET_MS; + + const [unavailableIdentity, setUnavailableIdentity] = useState( + null, + ); + + useEffect(() => { + if (input.hasContent) return; + + if (input.isError) { + const timer = setTimeout(() => setUnavailableIdentity(input.identity), 0); + + return () => clearTimeout(timer); + } + + const timer = setTimeout( + () => setUnavailableIdentity(input.identity), + timeoutMs, + ); + + return () => clearTimeout(timer); + }, [input.hasContent, input.identity, input.isError, timeoutMs]); + + if (input.hasContent) return "ready"; + + return unavailableIdentity === input.identity ? "unavailable" : "loading"; +} + +export { type InitialAvailability, useInitialAvailability };