Compare commits

...
9 Commits
Author SHA1 Message Date
ksv741 54deae57b3 chore(release): 1.0.0
Frontend image / publish (push) Successful in 7m39s
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 6m39s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m34s
2026-10-07 18:14:21 +03:00
ksv741 74f8a72ed5 perf(tbank-connection): poll screen sync every five seconds
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m57s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m21s
During active screen sync, status checks and observe reads run every 5s
instead of 1s; terminal state observes resources immediately.
OpenSpec change: WealthLedger/specs#21
2026-10-07 14:03:55 +03:00
ksv741 fcb938b5a2 chore: remove unused exports
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m16s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 14s
Frontend verify / verify (push) Failing after 6m28s
2026-10-07 09:10:03 +03:00
ksv741 f873db7b02 fix(deps): resolve npm audit advisories
Frontend verify / commits (pull_request) Successful in 36s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 32m34s
2026-10-07 07:27:16 +03:00
ksv741 0ea8d7b62d feat: default journal period to last 7 days
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Failing after 13s
Frontend verify / verify (pull_request) Skipped
2026-10-06 22:05:41 +03:00
ksv741 c25f6bd85b refactor: move PortfolioStructure to widgets layer 2026-10-06 22:05:24 +03:00
ksv741 5b30bb802a feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
2026-10-06 07:01:58 +03:00
ksv741 c096fc70ef fix(deps): remove npm dependency on Nexus
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 36m0s
Nexus hosts release Docker images, not npm dependencies.

Omit registry URLs using npm's native lockfile option.

Versions and integrity hashes remain unchanged.
2026-10-03 22:09:33 +03:00
ksv741 69997075e5 refactor!: simplify query and session ownership
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
2026-10-03 11:45:04 +03:00
108 changed files with 1863 additions and 3164 deletions
+8 -6
View File
@@ -8,7 +8,7 @@ Vitest — отдельную JSDOM конфигурацию. Точные versi
[package.json](../package.json), реализация — source of truth.
```text
app → pages → features → entities → shared
app → pages → widgets → features → entities → shared
page container → page VM → entity/feature query → shared/api → backend /api/v1
↓
props → state/section views
@@ -30,13 +30,15 @@ contract; frontend не читает его checkout для генерации
| --- | --- |
| Bootstrap, глобальные providers, routes | `app` |
| Композиция маршрута и поведение, нужное только этому экрану | `pages/<page>` |
| Крупная переиспользуемая композиция экрана | `widgets/<widget>` |
| Самостоятельная пользовательская возможность с собственным смыслом | `features/<capability>` |
| Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
сами по себе не требуют feature. Два callers не делают domain helper generic shared.
Локальный FSD profile: `app → pages → widgets → features → entities → shared`.
Widget собирает несколько нижних слоёв в самостоятельный UI-блок; компонент для
одной page остаётся у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
@@ -47,12 +49,12 @@ Slice именуется по ответственности, а не `helpers`,
Cross-slice consumer использует `@/` и `index.ts` владельца, например
`@/entities/portfolio`. Alias Vite и TypeScript согласован. Другой slice не
импортирует внутренний файл напрямую. Slices `pages`, `features`, `entities` не зависят от соседнего slice своего слоя;
импортирует внутренний файл напрямую. Slices `pages`, `widgets`, `features`, `entities` не зависят от соседнего slice своего слоя;
композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые.
Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
`pages`, `widgets`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement.
+13
View File
@@ -39,6 +39,10 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются.
Protected queries используют existing session-aware boundary; logout очищает
session-scoped cache. Access token хранится только в памяти, refresh передаётся
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
@@ -50,6 +54,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
от обычного фонового refetch. Даже повторное сохранение того же token начинает
новую generation; поздний ответ прежней generation не попадает в новый кэш.
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
без HTTP request registry, URL-wide latest-read counters и access epochs.
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
data/metadata, без подмены query status и отдельного transport state.
Status синхронизации содержит только состояние работы. Resources читаются
через `sync=observe` во время работы и после завершения, без hash comparison.
Согласованность income entries/groups обеспечивает backend; frontend не
дублирует её runtime validator.
Mutations используют существующий generated client и query-key owner;
после успеха обновляй/invalidируй только связанные данные. Optimistic update
нужен конкретному UX и обязан иметь rollback/error behavior.
+67
View File
@@ -1,5 +1,72 @@
# Changelog
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
### Features
* add quiet reading with rare confirmations ([5b30bb8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b30bb802a4ec43c036066f9a7e178f37e747a0b))
* default journal period to last 7 days ([0ea8d7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9))
### Bug Fixes
* **deps:** remove npm dependency on Nexus ([c096fc7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c096fc70ef442474c9b7766fdf5772b645166b92))
* **deps:** resolve npm audit advisories ([f873db7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f873db7b027b6dee0c7382b4bd3df5198c7a4198))
* **deps:** resolve npm audit advisories ([10baefd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/10baefda790e7fb491c893724ddc4da7992103f0))
* resolve frontend audit findings ([f6db28d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f6db28defa51bd9d8fe932ceae4c3c720aba7eb3))
* sort stock and bond events by payment date ([f5f4447](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f5f4447d60661aa92c80b50a16e8cb194dfa161f))
### Performance
* **tbank-connection:** poll screen sync every five seconds ([74f8a72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/74f8a72ed55af2348fa3668f57312f4fdfc1e8a8)), references [WealthLedger/specs#21](https://git.personal.ksv741.ru/WealthLedger/specs/issues/21)
### Refactoring
* address frontend review feedback ([a4046db](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f))
* clarify frontend model ownership ([0865b35](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0865b35f93865b4052181d918cee80c995312930))
* clarify frontend module ownership ([f9f0f69](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f9f0f6921e86f6c81510373c680b136e4580f386))
* encapsulate instrument chart data ([64fe902](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/64fe902192b60a72440635f5610ebe02cf8543f8))
* enforce enum machine codes [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5b69003](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b6900351612b6ba34c694fc99612733e7901c79))
* enforce frontend view model boundaries ([ca0ad4f](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ca0ad4f824a47fa47e32d4717e27b15c8a985f7d))
* move PortfolioStructure to widgets layer ([c25f6bd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c25f6bd85b88c4c7acb2f5ae6fc013aec77005b2))
* remove redundant page view models ([fb3a5a6](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fb3a5a6eaa0223a15ea2c9f585a06459371f0a25))
* share instrument search presentation ([32a7786](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32a77860c8eaea4a59a26e55b2d66976fd8f9484))
* share source sync status presentation ([0bc8bc3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0bc8bc36b7263b7f0120a08df840b290b40d8116))
* simplify auth and instrument pages ([b404c18](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b404c1802a74f5f369173fdfcb086a3d5760e35a))
* simplify calendar and form state ([94adbaf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/94adbafa344b23d7f95c31db975d99d990bfcb8b))
* simplify errors and focus tests ([7e976c3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e976c330c54715018eda4bff0764c5d4de9c8b0))
* simplify finance view models ([2844b22](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2844b22dbace41a02c1b5a07b3801e84c66b3528))
* simplify frontend state and mutation boundaries ([a7537da](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a7537da34452ab1a15138ab0c1cd93d53d06c517))
* simplify query and session ownership ([6999707](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/69997075e569da3edd43e242ef36f09311262412))
* simplify resource queries and layout tests ([c1b0873](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c1b08735e3a45d565c3836ae37ad76186bf48f26))
* tighten types and reuse instrument UI ([7c28a42](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7c28a423dd8a3cc4a5bdcb49b8de7018979df413))
* unify bank form types and actions ([ff67a76](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ff67a768addc0c4ba55247f5e2d9d5d5d5aec734))
### CI
* reduce duplicate actions and serialize checks ([95fdc87](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/95fdc8759e20f9baa4ac3d00e703752c79010e01))
### Tests
* remove redundant frontend checks ([320ea59](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/320ea5922a70ab81ce4f05c3c90eb7b6a664fa39))
### Styles
* pin ternary operator formatting [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([12c3b72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/12c3b72bfb32dff5f1ffdb36519c90f7ea852488))
### Maintenance
* add typed ESLint checks [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5763010](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/57630102e4bcc6b555dd546ba373a14a88256eed))
* finish frontend ESLint migration [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([72d7835](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/72d7835227340e349d99f57db134a635f276f6e9))
* guard presentation data boundaries ([6a89b08](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a89b08ee571562fea349801169c40f1f993e9b9))
* guard unsafe DOM HTML with ESLint [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([061ec70](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/061ec708c651029cd7d61d22319d11e7ac9f074e))
* remove unused exports ([fcb938b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fcb938b5a29fc82f00bbaa8b2ee776ca57dad010))
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
### Features
+11 -5
View File
@@ -29,17 +29,17 @@ module.exports = {
},
{
from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
path: "^src/(app|pages|widgets|features|entities|shared)/([^/]+)/",
},
name: "slices-must-use-public-api",
severity: "error",
to: {
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
path: String.raw`^src/(app|pages|widgets|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/",
},
},
{
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
from: { path: "^src/(pages|widgets|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports",
severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
@@ -48,18 +48,24 @@ module.exports = {
from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers",
severity: "error",
to: { path: "^src/(entities|features|pages|app)/" },
to: { path: "^src/(entities|features|widgets|pages|app)/" },
},
{
from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(features|pages|app)/" },
to: { path: "^src/(features|widgets|pages|app)/" },
},
{
from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(widgets|pages|app)/" },
},
{
from: { path: "^src/widgets/" },
name: "widgets-must-not-depend-on-higher-layers",
severity: "error",
to: { path: "^src/(pages|app)/" },
},
{
+98 -1291
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "wealth-ledger-frontend",
"private": true,
"version": "0.4.0",
"version": "1.0.0",
"type": "module",
"engines": {
"node": "24.21.0"
@@ -34,7 +34,7 @@
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
},
"lint-staged": {
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
},
"dependencies": {
"@hookform/resolvers": "5.9.1",
@@ -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",
-6
View File
@@ -6,12 +6,6 @@ const { version } = require("../package.json");
const git = (...arguments_) =>
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
assert.equal(
git("status", "--porcelain"),
"",
"Commit all changes before preparing a release",
);
const tag_ = git(
"tag",
"--merged",
+10 -6
View File
@@ -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();
});
});
});
+8 -1
View File
@@ -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 = () => {
</RouteErrorBoundary>
</AuthProvider>
</BrowserRouter>
<Notifications autoClose={4000} limit={2} position="top-right" zIndex={1000} />
</QueryClientProvider>
</DatesProvider>
</MantineProvider>
+2 -2
View File
@@ -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());
@@ -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_) {
@@ -47,7 +47,6 @@ const meta
error: false,
fetching: false,
loading: false,
retry: () => {},
},
component: BankSummaryView,
parameters: {
@@ -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<BankSummaryViewProps> = (props) => {
@@ -101,17 +100,7 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
<BankSummaryViewCategories {...props} />
{props.error && (
<Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
</Button>
</Group>
)}
{props.data && props.fetching && <Text role="status">{t("bank.refreshing")}</Text>}
{props.error && !props.data && <Text role="alert">{t("bank.error")}</Text>}
</Stack>
);
};
@@ -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({
@@ -210,7 +210,6 @@ export const IndependentFreshness: Story
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
stateVersion: "failed",
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "failed",
@@ -223,7 +222,6 @@ export const IndependentFreshness: Story
freshness: "unknown",
lastSuccessfulAt: null,
resourceKey: "portfolio/waiting",
stateVersion: "waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
},
],
@@ -64,8 +64,7 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
const { source } = props;
const { t } = useTranslation();
const isSyncing
= source?.sync.state === "running" || source?.sync.state === "queued";
const isSyncing = source?.sync.state === "running";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
@@ -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<BrokerageEventsPreviewProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{events.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={events.lastSuccessfulAt}
stale={events.stale}
/>
)}
{events.state === "loading" && (
<LoadingSkeleton label={t("events.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</LoadingSkeleton>
)}
{events.state === "error" && <Alert color="red">{t("events.recentError")}</Alert>}
{events.state === "error" && <Text c="dimmed">{t("states.unavailableText")}</Text>}
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}
+3 -8
View File
@@ -38,14 +38,10 @@ function useAuthProviderVm() {
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
// Cookie-writing requests share ordering, including across tabs with Web Locks.
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
const next = authQueueRef.current.then(async () =>
"locks" in navigator
? navigator.locks.request("wealth-ledger-refresh", run)
: run(),
);
const next = authQueueRef.current.then(run);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
authQueueRef.current = next.catch(() => {});
@@ -110,8 +106,7 @@ function useAuthProviderVm() {
}
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
}).finally(() => {
if (refreshPromiseRef.current === pending)
refreshPromiseRef.current = undefined;
refreshPromiseRef.current = undefined;
});
refreshPromiseRef.current = pending;
@@ -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({
@@ -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"
|| query.meta?.sync.state === "queued"),
retry: () => {
void (retry ?? query.refetch)();
},
sourceError: query.meta?.sync.state === "failed",
stale: query.meta?.freshness === "stale",
};
}
@@ -86,7 +86,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
<Group justify="space-between" mt="md">
<Button
disabled={!vm.hasPrevious || vm.operationsState.refreshing}
disabled={!vm.hasPrevious}
onClick={vm.onPrevious}
variant="default"
>
@@ -94,7 +94,7 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => {
</Button>
<Button
disabled={!vm.operations.hasNext || vm.operationsState.refreshing}
disabled={!vm.operations.hasNext}
onClick={vm.onNext}
variant="default"
>
@@ -1,21 +1,17 @@
import type { FC, PropsWithChildren, ReactNode } from "react";
import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Avatar, Group, Paper, Text, Title } from "@mantine/core";
import classes from "./InstrumentHeader.module.css";
type InstrumentHeaderProps = PropsWithChildren<{
logoSrc: null | string;
name: string;
onRefresh: () => void;
refreshing: boolean;
subtitle: ReactNode;
}>;
const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props;
const { t } = useTranslation();
const { children, logoSrc, name, subtitle } = props;
return (
<Paper className={classes.header} component="header" withBorder>
@@ -31,10 +27,6 @@ const InstrumentHeader: FC<InstrumentHeaderProps> = (props) => {
<Text c="dimmed" size="sm">{subtitle}</Text>
</div>
</div>
<Button disabled={refreshing} onClick={onRefresh} variant="subtle">
{t("investment.refresh")}
</Button>
</Group>
{children}
@@ -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<InstrumentPageStateProps> = (props) => {
const { code, kind, loading, retry } = props;
const { code, kind, loading } = props;
const { t } = useTranslation();
if (loading) return (
<LoadingSkeleton label={t(`${kind}.loading`)}>
@@ -55,10 +54,6 @@ const InstrumentPageState: FC<InstrumentPageStateProps> = (props) => {
<Anchor component={Link} to="/settings">
{t("accounts.openSettings")}
</Anchor>
<Button onClick={retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
);
};
@@ -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<InstrumentSectionProps> = (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<InstrumentSectionProps> = (props) => {
{props.controls}
</Group>
<SourceSyncStatus
lastSuccessfulAt={props.state.lastSuccessfulAt}
stale={props.state.stale}
/>
{!!props.state.partial && <Text role="status">{t("investment.partial")}</Text>}
{!!props.state.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
{!!props.state.error && (
{!!(props.state.error && !props.state.hasData) && (
<Alert aria-live="polite" color="orange" mb="sm" role="status">
<Group justify="space-between">
<Text size="sm">
{props.state.sourceError ? t("investment.syncFailed") : readError}
</Text>
<Button
disabled={props.state.refreshing}
onClick={props.state.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
<Text size="sm">{t("stock.sectionError")}</Text>
</Alert>
)}
@@ -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 = [<a href="/stocks/SBER" key="SBER">Сбербанк</a>];
@@ -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();
@@ -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<InstrumentSearchProps> = (props) => {
</Text>
</div>
<Button
disabled={vm.refreshing || !vm.canSearch}
onClick={vm.retry}
w="fit-content"
>
{t("investment.refresh")}
</Button>
{!!vm.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<TextInput
@@ -114,9 +103,6 @@ const InstrumentSearch: FC<InstrumentSearchProps> = (props) => {
{t("accounts.openSettings")}
</Anchor>
<Button onClick={vm.retry} variant="default">
{t("accounts.retry")}
</Button>
</Stack>
</Alert>
)}
-4
View File
@@ -1,6 +1,2 @@
export { useScreenSync } from "./model/screen-sync";
export {
InvestmentRefreshState,
type InvestmentRefreshStateProps,
} from "./ui/InvestmentRefreshState/InvestmentRefreshState.view";
export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
@@ -2,7 +2,6 @@ import type { QueryClient } from "@tanstack/react-query";
import {
apiQueryClient,
invalidateApiRequests,
type paths,
} from "@/shared/api";
@@ -27,7 +26,6 @@ function commitTbankConnectionBoundary(
queryClient: QueryClient,
status: TbankConnectionStatus,
) {
invalidateApiRequests(isInvestmentPath);
queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({
predicate: ({ queryKey }) => {
@@ -21,7 +21,7 @@ function auth() {
}
describe("page-owned screen synchronization", () => {
it("ignores a late sync acceptance after the account scope changes", async () => {
it("keeps the current scope when an older sync acceptance arrives last", async () => {
auth();
const { promise: blocked, resolve: release }
@@ -37,7 +37,7 @@ describe("page-owned screen synchronization", () => {
};
posted.push(scope.accountId);
await blocked;
if (scope.accountId === "old") await blocked;
return HttpResponse.json(
{ id: scope.accountId, now: "2026-09-25T00:00:00Z", scope },
@@ -49,7 +49,6 @@ describe("page-owned screen synchronization", () => {
return HttpResponse.json({
id: params.id,
resources: [],
state: "succeeded",
});
}),
@@ -69,16 +68,17 @@ describe("page-owned screen synchronization", () => {
});
await waitFor(() => expect(posted).toEqual(["old"]));
rerender({ accountId: "new" });
await act(() => result.current.refresh());
await waitFor(() => expect(polled).toEqual(["new"]));
await act(async () => {
release();
await pending;
});
await act(() => result.current.refresh());
await waitFor(() => expect(polled).toEqual(["new"]));
expect(polled).toEqual(["new"]);
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)[] = [];
@@ -91,7 +91,7 @@ describe("page-owned screen synchronization", () => {
return isUnavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { stateVersion: String(modes.length) }),
resource([], { dataRevision: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
@@ -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?.stateVersion).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<void>();
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([], { stateVersion: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return isChanged
? HttpResponse.json(resource([], { stateVersion: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
isRecovering = true;
await blocked;
return HttpResponse.json(resource([], { stateVersion: "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<void>;
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?.stateVersion).toBe("current"),
return HttpResponse.json(
resource(null, {
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "failed",
state: "failed",
},
}),
);
}),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "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",
resources: [resource([]).meta],
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).toEqual([null, "observe"]);
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 () => {
@@ -332,7 +341,7 @@ describe("page-owned screen synchronization", () => {
let polls = 0;
const meta = () =>
resource([], { stateVersion: isCompleted ? "2" : "1" }).meta;
resource([], { dataRevision: isCompleted ? "2" : "1" }).meta;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
@@ -361,8 +370,6 @@ describe("page-owned screen synchronization", () => {
errorCode: null,
id: "screen",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [meta()],
scope: { screen: "accounts" },
state: "succeeded",
},
@@ -390,7 +397,7 @@ describe("page-owned screen synchronization", () => {
]);
});
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("2"),
expect(result.current.accounts.meta?.dataRevision).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
@@ -413,7 +420,7 @@ describe("page-owned screen synchronization", () => {
it.each(["leave", "logout"])(
"publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.useFakeTimers();
try {
auth();
@@ -422,15 +429,13 @@ describe("page-owned screen synchronization", () => {
const modes: (null | string)[] = [];
const snapshot = () =>
resource(
isCompleted
? []
: null,
{
stateVersion: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
},
);
resource(isCompleted ? [] : null, {
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
unavailableReason: isCompleted
? undefined
: "CALENDAR_OR_ACCOUNT_HISTORY",
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
@@ -446,12 +451,6 @@ describe("page-owned screen synchronization", () => {
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
progress: {
completed: isCompleted ? 1 : 0,
discovering: true,
total: 2,
},
resources: [snapshot().meta],
scope: null,
state: "running",
},
@@ -471,30 +470,26 @@ describe("page-owned screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() =>
expect(result.current.accounts.meta?.sync.id).toBe("history"),
);
await waitFor(() => expect(polls).toBeGreaterThan(0));
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
expect(result.current.sync.refreshing).toBe(true);
expect(result.current.accounts.data).toBeUndefined();
isCompleted = true;
vi.setSystemTime(Date.now() + 61_000);
await waitFor(() => expect(result.current.accounts.data).toEqual([]), {
timeout: 3000,
});
expect(result.current.sync.refreshing).toBe(true);
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
});
expect(modes).toEqual([null, "observe"]);
isCompleted = true;
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() =>
expect(result.current.accounts.data).toEqual([]),
);
expect(result.current.sync.refreshing).toBe(true);
expect(modes).toEqual([null, "observe", "observe"]);
if (boundary === "leave") unmount();
else act(() => configureAuthClient());
const stopped = polls;
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 1100));
});
expect(polls).toBe(stopped);
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
@@ -502,6 +497,144 @@ describe("page-owned screen synchronization", () => {
},
);
it("runs the next status poll and observe read only after five seconds", async () => {
auth();
const modes: (null | string)[] = [];
const pollGaps: number[] = [];
let polls = 0;
let previousPollAt = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource([], {
sync: { errorCode: null, id: "history", state: "running" },
}),
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
const now = Date.now();
if (previousPollAt) pollGaps.push(now - previousPollAt);
previousPollAt = now;
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "running",
});
}),
);
vi.useFakeTimers();
try {
const { unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
await act(() => vi.advanceTimersByTimeAsync(2500));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(polls).toBe(2));
expect(modes).toEqual([null, "observe", "observe"]);
expect(pollGaps).toHaveLength(1);
expect(pollGaps[0]).toBeGreaterThanOrEqual(5000);
unmount();
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
});
it("observes resources immediately when a status response turns terminal", async () => {
auth();
const modes: (null | string)[] = [];
let isCompleted = false;
let polls = 0;
const meta = () =>
resource([], {
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(meta());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: isCompleted ? "succeeded" : "running",
});
}),
);
vi.useFakeTimers();
try {
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
expect(result.current.sync.refreshing).toBe(true);
isCompleted = true;
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(modes).toEqual([null, "observe", "observe"]));
expect(polls).toBe(2);
expect(result.current.accounts.meta?.dataRevision).toBe("covered");
expect(result.current.sync.refreshing).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
});
it("clears private snapshots when status confirms access loss", async () => {
auth();
server.use(
@@ -545,8 +678,8 @@ describe("page-owned screen synchronization", () => {
[],
{
dataRevision: isObserved ? "covered" : "initial",
resourceKey: "operations",
stateVersion: isObserved ? "covered" : "initial",
sync: {
errorCode: null,
id: "dependency",
@@ -570,7 +703,7 @@ describe("page-owned screen synchronization", () => {
dependencies: [
{
...dependency,
stateVersion: isObserved ? "covered" : "initial",
dataRevision: isObserved ? "covered" : "initial",
},
],
resourceKey: "projection",
@@ -585,8 +718,6 @@ describe("page-owned screen synchronization", () => {
errorCode: null,
id: "dependency",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [{ ...dependency, stateVersion: "covered" }],
scope: null,
state: "succeeded",
},
@@ -1,5 +1,10 @@
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
focusManager,
onlineManager,
type QueryKey,
useQueryClient,
} from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import {
ApiErrorCode,
@@ -8,103 +13,82 @@ import {
invalidateResourceAccess,
type InvestmentResourceMeta,
isApiErrorCode,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
import { type InvestmentScreen, useTbankSync } from "./sync";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
type ScreenResource = {
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
observe: (version?: string) => Promise<unknown>;
observe: () => Promise<unknown>;
queryKey: QueryKey;
transport: {
fetchStatus: "fetching"
| "idle"
| "paused";
status: "error"
| "pending"
| "success";
};
refetch: () => Promise<unknown>;
};
const STATUS_INTERVAL_MS = 1000;
const STATUS_INTERVAL_MS = 5000;
const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000;
const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000;
function observeResource(
resource: ScreenResource,
latest: Map<string, InvestmentResourceMeta>,
completedId: string | undefined,
observed: Map<string, string>,
) {
if (!resource.meta) {
if (!completedId || resource.transport.fetchStatus !== "idle") return;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (observed.get(key) === completedId) return;
observed.set(key, completedId);
void resource.observe().catch(() => {});
function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean {
if (!meta || meta.unavailableReason) return false;
return;
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;
}
}
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
].flatMap((current) => {
const notified = latest.get(current.resourceKey);
return notified && notified.stateVersion !== current.stateVersion
? [notified]
: [];
});
if (changed.length === 0) return;
const notification = JSON.stringify(
changed.map(meta => [meta.resourceKey, meta.stateVersion]),
);
if (observed.get(resource.meta.resourceKey) === notification) return;
observed.set(resource.meta.resourceKey, notification);
const own = changed.find(
meta => meta.resourceKey === resource.meta?.resourceKey,
);
void resource.observe(own?.stateVersion).catch(() => {});
}
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = useTbankSync();
const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
const client = useQueryClient();
const deniedRef = useRef(new Set<unknown>());
const scopeKey = JSON.stringify(scope);
const [requested, setRequested] = useState<null
| {
id: string;
scope: string;
}>(null);
const requestInFlightRef = useRef<null | string>(null);
const currentScopeRef = useRef(scopeKey);
useLayoutEffect(() => {
currentScopeRef.current = scopeKey;
}, [scopeKey]);
const lifecycleRecoveryRef = useRef(new Set<string>());
const sourceRecoveryRef = useRef(new Map<string, number>());
const sourceTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
const isCurrentMutation
= JSON.stringify(mutation.variables?.body) === scopeKey;
const observedRef = useRef(new Map<string, string>());
const observedRef = useRef("");
const ids = [
...new Set([
...(requested?.scope === scopeKey ? [requested.id] : []),
...(isCurrentMutation && mutation.data ? [mutation.data.id] : []),
...resources
.flatMap(({ meta }) =>
meta ? [meta, ...(meta.dependencies ?? [])] : [],
)
.flatMap(meta =>
meta.sync.id
&& (meta.sync.state === "running" || meta.sync.state === "queued")
&& meta.sync.state === "running"
? [meta.sync.id]
: [],
),
@@ -140,33 +124,96 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
invalidateResourceAccess(client, "brokerage", status.error);
}
const latest = new Map<string, InvestmentResourceMeta>();
if (statuses.every(status => !status.data)) return;
const orderedStatuses = statuses.toSorted(
(a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
const observation = JSON.stringify([
scopeKey,
statuses.map(status => [
status.data?.id,
status.data?.state,
status.dataUpdatedAt,
]),
resources.map(resource => resource.queryKey),
]);
if (observedRef.current === observation) return;
observedRef.current = observation;
const isCompleted = statuses.every(
status => status.data && status.data.state !== "running",
);
for (const status of orderedStatuses) {
const statusResources = status.data?.resources ?? [];
for (const meta of statusResources) {
latest.set(meta.resourceKey, meta);
const dependencies = meta.dependencies ?? [];
for (const dependency of dependencies)
latest.set(dependency.resourceKey, dependency);
}
}
const completed = statuses.find(
status =>
requested?.scope === scopeKey
&& status.data?.id === requested.id
&& status.data.state !== "running",
)?.data;
for (const resource of resources)
observeResource(resource, latest, completed?.id, observedRef.current);
}, [client, statuses, resources, requested, scopeKey]);
if (isCompleted || !resource.isFetching)
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)
@@ -179,7 +226,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
return (
!status?.isError
&& (!status?.data || status.data.state === "running")
&& (meta?.sync.state === "running" || meta?.sync.state === "queued")
&& meta?.sync.state === "running"
);
});
@@ -188,12 +235,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
requestInFlightRef.current = scopeKey;
try {
const accepted = await mutation.mutateAsync({ body: scope });
if (
currentScopeRef.current === scopeKey
&& requestInFlightRef.current === scopeKey
)
setRequested({ id: accepted.id, scope: scopeKey });
await mutation.mutateAsync({ body: scope });
}
catch {
/*
@@ -212,7 +254,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
resource => resource.transport.status === "error",
resource => resource.isError,
);
const failedStatuses = statuses.filter(status => status.isError);
@@ -238,4 +280,4 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
};
}
export { useScreenSync };
export { type InvestmentScreen, useScreenSync };
@@ -8,13 +8,14 @@ import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import type { InvestmentScreen } from "./screen-sync";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
import { type InvestmentScreen, useTbankSync } from "./sync";
describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])(
"discards pending status and mutation after %s changes",
"keeps old status out of the cache after %s changes",
async (boundary) => {
let generation = 0;
configureAuthClient({
@@ -57,8 +58,6 @@ describe("sync connection and session boundaries", () => {
...accepted,
errorCode: null,
now,
progress: { completed: 1, discovering: false, total: 1 },
resources: [],
state: "succeeded",
},
);
@@ -84,7 +83,11 @@ describe("sync connection and session boundaries", () => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return { mutation: useTbankSync() };
return {
mutation: apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
}),
};
},
{ wrapper },
);
@@ -95,10 +98,8 @@ describe("sync connection and session boundaries", () => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const discarded = expect(mutation).rejects.toMatchObject({
name: "AbortError",
});
// eslint-disable-next-line unicorn/prefer-await -- Handle rejection before changing the boundary and releasing the pending response.
const settled = mutation.catch(() => {});
await waitFor(() => expect(started).toBe(2));
await act(async () => {
@@ -121,8 +122,7 @@ describe("sync connection and session boundaries", () => {
release();
await blocked;
});
await discarded;
expect(result.current.mutation.data).toBeUndefined();
await settled;
const key = apiQueryClient.queryOptions(
"get",
@@ -1,12 +0,0 @@
import { apiQueryClient, type paths } from "@/shared/api";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
function useTbankSync() {
return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
}
export { type InvestmentScreen, useTbankSync };
@@ -58,4 +58,4 @@ const InvestmentRefreshState: FC<InvestmentRefreshStateProps> = (props) => {
);
};
export { InvestmentRefreshState, type InvestmentRefreshStateProps };
export { InvestmentRefreshState };
@@ -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();
@@ -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"),
});
},
});
@@ -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,
@@ -21,6 +20,7 @@ import {
import { beforeEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
@@ -115,7 +115,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 +132,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,21 +152,13 @@ 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 },
]),
);
},
);
it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = [];
server.use(
http.get(
@@ -215,24 +197,28 @@ describe("Upcoming event date filters", () => {
),
);
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]);
expect(searches[0]).toBe("");
expect(searches).toContain(`?from=${defaultFrom}&to=${defaultTo}`);
expect(searches).toContain("?from=2099-02-01");
expect(searches).toContain("?to=2099-02-28");
},
);
it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
const [defaultFrom] = defaultPeriod();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(resource({ events: [] })),
new URL(request.url).searchParams.get("from") === defaultFrom
? HttpResponse.json(resource({ events: [] }))
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
),
);
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();
@@ -242,6 +228,7 @@ describe("Upcoming event date filters", () => {
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
const [defaultFrom, defaultTo] = defaultPeriod();
const searches: string[] = [];
server.use(
http.get(
@@ -260,38 +247,42 @@ describe("Upcoming event date filters", () => {
},
),
);
renderEvents(accountId, ["?from=2099-02-01&to=2099-02-28"]);
renderEvents(accountId, ["?from=2026-02-01&to=2026-02-28"]);
const from = await screen.findByLabelText("Период");
expect(from).toHaveTextContent("01.02.2099 – 28.02.2099");
expect(from).toHaveTextContent("01.02.2026 – 28.02.2026");
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(searches).toEqual(["?from=2099-02-01&to=2099-02-28"]);
expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]);
fireEvent.click(from);
fireEvent.click(
await screen.findByRole("button", { name: /10 February 2099/i }),
await screen.findByRole("button", { name: /10 February 2026/i }),
);
fireEvent.click(
await screen.findByRole("button", { name: /28 February 2099/i }),
await screen.findByRole("button", { name: /28 February 2026/i }),
);
expect(searches).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Применить" }));
await waitFor(() =>
expect(searches.at(-1)).toBe("?from=2099-02-10&to=2099-02-28"),
expect(searches.at(-1)).toBe("?from=2026-02-10&to=2026-02-28"),
);
expect(await screen.findByLabelText("Период")).toHaveTextContent(
"10.02.2099 – 28.02.2099",
"10.02.2026 – 28.02.2026",
);
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent(
"01.02.2099 – 28.02.2099",
"01.02.2026 – 28.02.2026",
),
);
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01&to=2099-02-28",
"?from=2026-02-01&to=2026-02-28",
);
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await waitFor(() => expect(searches.at(-1)).toBe(""));
expect(await screen.findByLabelText("Период")).toHaveTextContent("");
await waitFor(() =>
expect(searches.at(-1)).toBe(`?from=${defaultFrom}&to=${defaultTo}`),
);
expect(await screen.findByLabelText("Период")).toHaveTextContent(
`${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`,
);
expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled();
expect(
screen.getByText("За выбранный период событий не найдено"),
@@ -300,7 +291,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 +334,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",
}),
);
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as BrokerageEvents from "@/entities/brokerage-event";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { defaultPeriod } from "@/shared/lib";
import { AccountEventsPage } from "./AccountEventsPage";
@@ -71,7 +72,10 @@ describe("AccountEventsPage", () => {
</MemoryRouter>
</MantineProvider>,
);
expect(query).toHaveBeenCalledWith("selected-account", {});
expect(query).toHaveBeenCalledWith("selected-account", {
from: defaultPeriod()[0],
to: defaultPeriod()[1],
});
});
it("keeps the loading fallback when no result or account is available", () => {
@@ -88,7 +92,10 @@ describe("AccountEventsPage", () => {
} as never,
);
renderPage();
expect(query).toHaveBeenCalledWith("", {});
expect(query).toHaveBeenCalledWith("", {
from: defaultPeriod()[0],
to: defaultPeriod()[1],
});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
@@ -159,8 +166,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 +175,6 @@ describe("AccountEventsPage", () => {
data: undefined,
isError: true,
isLoading: false,
refetch,
} as never);
rerender(
<MantineProvider>
@@ -178,8 +183,8 @@ describe("AccountEventsPage", () => {
</MemoryRouter>
</MantineProvider>,
);
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", () => {
@@ -6,25 +6,28 @@ import {
useBrokerageEvents,
} from "@/entities/brokerage-event";
import { useScreenSync } from "@/features/tbank-connection";
import { isValidPeriod, useUrlPeriod } from "@/shared/lib";
import {
defaultPeriod,
isValidPeriod,
moscowDate,
useInitialAvailability,
useUrlPeriod,
} from "@/shared/lib";
type AccountEventsPageViewModel
= | {
canApply: boolean;
draftPeriod: [null | string, null | string];
events: BrokerageEvent[];
lastSuccessfulAt?: null | string;
maxDate: string;
onApply: () => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => 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 {
@@ -43,6 +46,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const [draftFrom, draftTo] = draftPeriod;
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const scope = JSON.stringify([accountId, appliedFrom, appliedTo]);
const [previousScope, setPreviousScope] = useState(scope);
@@ -56,7 +61,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(appliedTo && { to: appliedTo }),
});
const sync = useScreenSync(
useScreenSync(
{
accountId,
screen: "events",
@@ -66,22 +71,30 @@ 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]);
setDraftPeriod([appliedFrom, appliedTo]);
setSearchParameters({});
};
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 +102,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
&& isValidPeriod(draftFrom, draftTo),
draftPeriod,
events: events.data.events,
lastSuccessfulAt: events.meta?.lastSuccessfulAt,
maxDate,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
@@ -97,15 +110,12 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
...(draftTo && { to: draftTo }),
});
},
onPeriodChange: setDraftPeriod,
onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
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",
};
}
@@ -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<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
@@ -13,8 +13,6 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
<StateView
action={(
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
@@ -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<AccountEventsPageLoadedProps> = (props) => {
return (
<Stack gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("events.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.refreshing && <Progress animated aria-label={t("accounts.refreshing")} value={100} />}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
)}
<Text c="dimmed">{t("events.hint")}</Text>
<Group align="end">
@@ -89,9 +65,11 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
maxDate={props.maxDate}
onChange={props.onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true,
}}
type="range"
@@ -44,71 +44,6 @@ function summary(currency: string, coupon: string, dividend: string) {
};
}
describe("income membership validation", () => {
const entry: IncomeAnalytics["entries"][number] = {
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "coupon-1",
instrumentKey: "income-group",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-01T12:00:00Z",
payment: money("RUB", "10"),
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
const instrument: IncomeAnalytics["instruments"][number] = {
...summary("RUB", "10", "0"),
displayName: "BOND",
figi: "FIGI",
instrumentType: "bond",
instrumentUid: null,
key: entry.instrumentKey,
receiptCount: 1,
ticker: "BOND",
};
const valid: IncomeAnalytics = {
...analytics,
entries: [entry],
instruments: [instrument],
totalsByCurrency: [summary("RUB", "10", "0")],
};
it.each([
["missing key", undefined],
["empty key", ""],
["whitespace key", " "],
["non-string key", 12],
["unknown key", "not-in-breakdown"],
])("rejects an entry with %s", (_name, instrumentKey) => {
// Deliberately malformed API data exercises the runtime validation boundary.
const response = {
...valid,
entries: [{ ...entry, instrumentKey }],
} as unknown as IncomeAnalytics;
expect(toIncomeDisplayModel(response)).toBeNull();
});
it.each([
["duplicate instrument keys", { instruments: [instrument, instrument] }],
["currency mismatch", { entries: [{ ...entry, payment: money("USD", "10") }] }],
["receipt count mismatch", { instruments: [{ ...instrument, receiptCount: 2 }] }],
["missing entries", { entries: [] }],
["empty instrument key", { instruments: [{ ...instrument, key: "" }] }],
])("rejects %s", (_name, invalid) => {
expect(toIncomeDisplayModel({ ...valid, ...invalid })).toBeNull();
});
});
describe("income comparison display", () => {
it.each([
["25", "100", "+25 %"],
+1 -41
View File
@@ -97,49 +97,14 @@ function formatIncomeChange(
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
meta?: IncomeAnalyticsMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
{
currency: string;
includedCount: number;
receiptCount: number;
}
>();
const currencies = new Set(
analytics.totalsByCurrency.map(({ currency }) => currency),
);
for (const instrument of analytics.instruments) {
if (
typeof instrument.key !== "string"
|| !instrument.key.trim()
|| instruments.has(instrument.key)
|| !currencies.has(instrument.total.currency)
)
return null;
instruments.set(instrument.key, {
currency: instrument.total.currency,
includedCount: 0,
receiptCount: instrument.receiptCount,
});
}
): IncomeDisplayModel {
const entries: IncomeDisplayModel["entries"] = [];
const occurrences = new Map<string, number>();
for (const entry of analytics.entries) {
if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim())
return null;
const instrument = instruments.get(entry.instrumentKey);
if (!instrument || entry.payment?.currency !== instrument.currency)
return null;
const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]);
const occurrence = occurrences.get(occurrenceKey) ?? 0;
occurrences.set(occurrenceKey, occurrence + 1);
instrument.includedCount += 1;
entries.push({
key: JSON.stringify([
entry.instrumentKey,
@@ -151,11 +116,6 @@ function toIncomeDisplayModel(
});
}
for (const instrument of instruments.values()) {
if (instrument.includedCount !== instrument.receiptCount)
return null;
}
const dependencies = meta?.dependencies ?? [];
const previous = dependencies.find(({ role }) => role === "previous");
const year = dependencies.find(({ role }) => role === "year");
@@ -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 () => {
@@ -324,32 +304,6 @@ describe("AccountIncomePage", () => {
expect(within(panel).getAllByRole("listitem")).toHaveLength(2);
});
it("rejects inconsistent analytics and recovers through retry", async () => {
let response = {
...analytics,
entries: [
{ ...analytics.entries[0], instrumentKey: "not-in-breakdown" },
analytics.entries[1],
],
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(response)),
),
);
renderPage();
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(
screen.queryByRole("region", { name: "Выплаты" }),
).not.toBeInTheDocument();
response = analytics;
fireEvent.click(retry);
expect(
await screen.findByRole("region", { name: "Выплаты" }),
).toBeInTheDocument();
});
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
@@ -512,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",
@@ -521,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();
});
});
@@ -657,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("Период");
@@ -679,53 +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",
progress: { completed: 1, discovering: false, total: 1 },
resources: [resource(null, { stateVersion: "2" }).meta],
scope: null,
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(resource(analytics, { stateVersion: "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",
);
@@ -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<typeof useForm<IncomeForm>>;
formRevision: string;
lastSuccessfulAt?: null | string;
onReset: () => void;
onSubmit: (values: IncomeForm) => void;
partial?: boolean;
refreshError: boolean;
refreshing: boolean;
retry: () => Promise<unknown> | undefined;
sourceError?: boolean;
state: "loaded";
}
| {
period: IncomePeriod;
retry: () => Promise<unknown> | 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,19 +76,22 @@ 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);
if (!display)
return {
period,
retry: () => analytics.observe(),
state: "error",
};
const onSubmit = (values: IncomeForm) => {
const from = parseDraftDate(values.from);
@@ -115,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",
};
};
@@ -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<AccountIncomePageViewModel, { state: "error" }>;
const AccountIncomePageError: FC<ErrorProps> = (props) => {
const AccountIncomePageError: FC<ErrorProps> = () => {
const { t } = useTranslation();
return (
<StateView
action={<Button onClick={props.retry}>{t("income.retry")}</Button>}
error
text={t("income.errorText")}
title={t("income.errorTitle")}
@@ -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<LoadedProps> = (props) => {
};
const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
const { display, form, onReset, onSubmit, refreshError, refreshing, retry }
= props;
const { display, form, onReset, onSubmit } = props;
const { t } = useTranslation();
return (
<Stack className={classes.page} gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("income.title")}</Title>
<Button disabled={refreshing} onClick={retry} w="fit-content">
{t("investment.refresh")}
</Button>
<FormProvider {...form}>
<IncomePeriodForm
key={props.formRevision}
@@ -63,32 +50,6 @@ const AccountIncomePageLoaded: FC<LoadedProps> = (props) => {
/>
</FormProvider>
{!!refreshing && (
<Group aria-label={t("income.refreshing")} gap="xs" role="status">
<Loader size="sm" />
<Text>{t("income.refreshing")}</Text>
</Group>
)}
{!!refreshError && (
<Alert aria-live="polite" color="orange" role="status">
<Stack gap="sm">
<Text>
{t(
props.sourceError
? "investment.syncFailed"
: "income.refreshError",
)}
</Text>
<Button onClick={retry} variant="default">
{t("income.retry")}
</Button>
</Stack>
</Alert>
)}
<AccountIncomePageLoadedSummary {...props} />
<Alert color="blue">{t("income.taxBoundary")}</Alert>
@@ -18,6 +18,7 @@ import {
LedgerEntryRow,
} from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api";
import { defaultPeriod } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
@@ -312,18 +313,20 @@ describe("AccountOperationsPage", () => {
"allows resetting filters after a request failure for %s",
async (accountId) => {
configureTestAuth();
const [defaultFrom] = defaultPeriod();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
new URL(request.url).searchParams.get("from")
=== `${defaultFrom}T00:00:00.000Z`
? HttpResponse.json(
resource(
{ hasNext: false, items: [], nextCursor: null },
),
),
)
: HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }),
),
);
render(
@@ -346,7 +349,7 @@ describe("AccountOperationsPage", () => {
</MantineProvider>
</QueryClientProvider>,
);
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 +487,9 @@ describe("AccountOperationsPage", () => {
);
renderPage();
expect(
await screen.findByText("Не удалось загрузить журнал"),
await screen.findByText("Не удалось загрузить журнал", undefined, {
timeout: 4000,
}),
).toBeInTheDocument();
});
});
@@ -520,7 +525,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 +535,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 +587,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]);
@@ -11,7 +11,13 @@ 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 {
defaultPeriod,
isValidPeriod,
moscowDate,
useInitialAvailability,
useUrlPeriod,
} from "@/shared/lib";
const operationCategorySet = new Set<string>(operationCategories);
@@ -24,21 +30,18 @@ type AccountOperationsPageViewModel
hasPrevious: boolean;
historyLoading?: boolean;
items: BrokerageOperation[];
lastSuccessfulAt?: null | string;
maxDate: string;
onApply: () => void;
onCategoriesChange: (value: string[]) => void;
onNext: () => void;
onPeriodChange: (value: [null | string, null | string]) => void;
onPeriodPopoverOpen: () => void;
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 => {
@@ -66,6 +69,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const [draftCategories, setDraftCategories]
= useState<OperationCategory[]>(appliedCategories);
const [maxDate, setMaxDate] = useState(() => moscowDate(new Date()));
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
@@ -85,37 +90,52 @@ 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]);
setDraftPeriod([appliedFrom, appliedTo]);
setDraftCategories([]);
setSearchParameters({});
history.reset();
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 +153,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
operations.meta?.coverage?.historyState === "loading"
|| operations.meta?.coverage?.historyState === "partial",
items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
maxDate,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParameters({
@@ -151,14 +171,13 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
),
),
onNext: history.onNext,
onPeriodChange: setDraftPeriod,
onPeriodChange: value => setDraftPeriod(
value[0] === null && value[1] === null ? defaultPeriod() : value,
),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
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",
};
};
@@ -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<AccountOperationsPageErrorProps> = (
props,
) => {
const { retry } = props;
const { t } = useTranslation();
return (
<StateView
action={(
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
</Group>
<Button onClick={props.onReset} variant="default">
{t("ledger.reset")}
</Button>
)}
error
text={t("ledger.errorText")}
title={t("ledger.errorTitle")}
/>
@@ -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,43 +60,23 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
return (
<Stack gap="lg">
<SourceSyncStatus lastSuccessfulAt={props.lastSuccessfulAt} />
<Title order={1}>{t("ledger.title")}</Title>
<Button disabled={props.refreshing} onClick={props.retry} w="fit-content">
{t("investment.refresh")}
</Button>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
{!!props.refreshing && <Progress animated aria-label={t("accounts.refreshing")} value={100} />}
{!!props.refreshError && (
<Alert aria-live="polite" color="yellow" role="status">
{t(
props.sourceError
? "investment.syncFailed"
: "accounts.refreshError",
)}
<Button onClick={props.retry} variant="subtle">
{t("accounts.retry")}
</Button>
</Alert>
)}
<Group align="end">
<DatePickerInput
allowSingleDateInRange
clearable
clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }}
label={t("ledger.period")}
maxDate={props.maxDate}
onChange={props.onPeriodChange}
popoverProps={{
middlewares: { shift: { crossAxis: true } },
onOpen: props.onPeriodPopoverOpen,
returnFocus: true,
}}
type="range"
+9 -18
View File
@@ -10,20 +10,18 @@ const RECENT_EVENTS_LIMIT = 5;
type AccountPageIncomeSummary
= | {
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded";
summary: IncomeSummary;
to: string;
}
| { retry: () => Promise<unknown> | 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<T> = {
function toIncomeSummary(
accountId: string | undefined,
income: PreviewResource<IncomeSummary>,
retry: () => Promise<unknown>,
): 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,
};
@@ -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(<AccountPageError code={code} retry={vi.fn()} />);
renderPage(<AccountPageError code={code} />);
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(<AccountPageError code={code} retry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(retry).toHaveBeenCalledOnce();
});
it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])(
"shows neutral unavailable without retry for %s",
(code) => {
renderPage(<AccountPageError code={code} />);
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
= {
@@ -786,11 +787,6 @@ describe("AccountPage states", () => {
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
progress: { completed: 1, discovering: false, total: 1 },
resources: [
resource(null, { resourceKey: "portfolio", stateVersion: "2" })
.meta,
],
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
@@ -826,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(
@@ -871,7 +857,7 @@ describe("AccountPage states", () => {
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
},
{ resourceKey: "portfolio", stateVersion: "2" },
{ dataRevision: "2", resourceKey: "portfolio" },
),
),
);
@@ -880,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(
@@ -921,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)));
});
@@ -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}
/>
);
};
@@ -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<string, unknown>
@@ -219,53 +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, { resourceKey: path, stateVersion: version }),
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",
progress: { completed: 4, discovering: false, total: 4 },
resources: Object.keys(sections).map(
resourceKey =>
resource(null, { resourceKey, stateVersion: version }).meta,
),
scope: null,
state: "succeeded",
},
),
),
);
const { result } = renderHook(() => useAccountPageViewModel(), {
@@ -280,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");
});
@@ -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<unknown> | undefined;
sourceError?: boolean;
};
type AccountPageEmptyViewModel = AccountPageContent & {
@@ -54,9 +51,16 @@ type AccountPageLoadedViewModel = AccountPageContent & {
type AccountPageViewModel
= | AccountPageEmptyViewModel
| AccountPageLoadedViewModel
| { code?: string; retry: () => Promise<unknown> | 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,
};
}
@@ -7,7 +7,7 @@ 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 { PortfolioStructure } from "@/widgets/portfolio-structure";
import type {
AccountPageIncomeSummary,
@@ -17,7 +17,6 @@ import type {
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
@@ -26,10 +25,6 @@ type AccountPageEmptyProps = {
incomeSummary?: AccountPageIncomeSummary;
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
@@ -45,14 +40,6 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
<Stack>
<AccountHeading account={props.account} accountId={props.accountId} />
<InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
<StateView text={text} title={t("portfolio.emptyTitle")} />
<PortfolioStructure
@@ -7,10 +7,10 @@ import { Link } from "react-router";
import { ApiErrorCode, isApiErrorCode } from "@/shared/api";
type AccountPageErrorProps = { code?: string; retry: () => void };
type AccountPageErrorProps = { code?: string };
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
const { code, retry } = props;
const { code } = props;
const { t } = useTranslation();
if (isApiErrorCode(code, ApiErrorCode.NotFound))
return (
@@ -25,6 +25,7 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.accountUnavailableTitle")}
/>
);
if (
isApiErrorCode(code, ApiErrorCode.TbankNotConnected)
|| isApiErrorCode(code, ApiErrorCode.InvalidTbankToken)
@@ -41,22 +42,11 @@ const AccountPageError: FC<AccountPageErrorProps> = (props) => {
title={t("portfolio.connectionErrorTitle")}
/>
);
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
text={t("states.unavailableText")}
title={t("states.unavailableTitle")}
/>
);
};
@@ -1,14 +1,13 @@
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 { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { AccountPagePositionSection } from "../../../model/positions";
import type {
@@ -21,7 +20,6 @@ import classes from "../AccountPage.module.css";
import { AccountHeading } from "./AccountHeading.view";
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view";
@@ -34,10 +32,6 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations;
refreshError?: boolean;
refreshing?: boolean;
retry?: () => void;
sourceError?: boolean;
};
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -55,27 +49,6 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} />
<InvestmentRefreshState
labelScope="portfolio"
refreshError={props.refreshError ?? false}
refreshing={props.refreshing ?? false}
retry={props.retry}
sourceError={props.sourceError}
/>
{!!props.portfolio.isStale && <Alert color="blue">{t("portfolio.staleWarning")}</Alert>}
<Text c="dimmed" size="sm">
{t(
props.accountId === "all"
? "portfolio.allUpdatedAt"
: "portfolio.updatedAt",
{
date: formatSnapshotTime(props.portfolio.lastSuccessfulAt),
},
)}
</Text>
{props.partial
? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -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<IncomeSummaryProps> = (props) => {
const { t } = useTranslation();
const errorText
= props.state === "loaded" && props.refreshError
? t("income.refreshError")
: null;
if (props.state === "loading" || props.state === "partial")
return (
<Text c="dimmed" role="status">
@@ -45,9 +45,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
if (props.state === "error")
return (
<StateView
action={
<Button onClick={props.retry}>{t("income.summaryRetry")}</Button>
}
error
text={t("income.errorText")}
title={t("income.summaryError")}
@@ -66,12 +63,6 @@ const IncomeSummary: FC<IncomeSummaryProps> = (props) => {
return (
<Stack gap="xs">
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={props.lastSuccessfulAt}
stale={props.stale}
/>
{props.summary.totalsByCurrency.map(total => (
<Paper key={total.currency} p="sm" withBorder>
<Text fw={700}>{formatMoney(total.total)}</Text>
@@ -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<RecentOperationsProps> = (props) => {
const { t } = useTranslation();
const { operations } = props;
const errorText
= operations.state === "loaded" && operations.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{operations.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={operations.lastSuccessfulAt}
stale={operations.stale}
/>
)}
{operations.state === "loading" && (
<LoadingSkeleton label={t("ledger.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</LoadingSkeleton>
)}
{operations.state === "error" && <Alert color="red">{t("ledger.recentError")}</Alert>}
{operations.state === "error" && <Text c="dimmed">{t("states.unavailableTitle")}</Text>}
{operations.state === "empty" && <Text c="dimmed">{t("ledger.recentEmpty")}</Text>}
@@ -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(
<MantineProvider>
<MemoryRouter>
<AccountsPageError code="TBANK_UNAVAILABLE" retry={retry} />
<AccountsPageError code="TBANK_NOT_CONNECTED" />
</MemoryRouter>
</MantineProvider>,
);
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.
@@ -10,25 +10,11 @@ const AccountsPage: FC = () => {
const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (viewModel.state === "empty")
return (
<AccountsPageEmpty
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
if (viewModel.state === "empty") return <AccountsPageEmpty />;
return (
<AccountsPageLoaded
accounts={viewModel.accounts}
lastSuccessfulAt={viewModel.lastSuccessfulAt}
refreshError={viewModel.refreshError}
refreshing={viewModel.refreshing}
retry={viewModel.retry}
sourceError={viewModel.sourceError}
/>
);
};
@@ -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,
};
}
@@ -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<InvestmentRefreshStateProps> = (props) => {
const AccountsPageEmpty: FC = () => {
const { t } = useTranslation();
return (
<Stack>
<InvestmentRefreshState {...props} />
<StateView
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
/>
</Stack>
<StateView
text={t("accounts.emptyText")}
title={t("accounts.emptyTitle")}
/>
);
};
@@ -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<AccountsPageErrorProps> = (props) => {
const { code, retry } = props;
const { code } = props;
const { t } = useTranslation();
const settingsAction = (
@@ -37,28 +37,11 @@ const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
title={t("accounts.invalidTokenTitle")}
/>
);
if (isApiErrorCode(code, ApiErrorCode.TbankUnavailable))
return (
<StateView
action={(
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
)}
error
text={t("accounts.unavailableText")}
title={t("accounts.unavailableTitle")}
/>
);
return (
<StateView
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
error
text={t("states.errorText")}
title={t("states.errorTitle")}
text={t("states.unavailableText")}
title={t("states.unavailableTitle")}
/>
);
};
@@ -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<AccountsPageLoadedProps> = (props) => {
const { accounts, ...refresh } = props;
const { t } = useTranslation();
return (
@@ -31,8 +26,6 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
</Text>
</Stack>
<InvestmentRefreshState {...refresh} />
<Button
component={Link}
to="/accounts/all"
@@ -42,7 +35,7 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
{t("accounts.openAll")}
</Button>
<BrokerageAccountList accounts={accounts} />
<BrokerageAccountList accounts={props.accounts} />
</Stack>
);
};
+3 -33
View File
@@ -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" });
-1
View File
@@ -15,7 +15,6 @@ const BondPage: FC = () => {
code={vm.code}
kind="bond"
loading={vm.bondState.loading}
retry={vm.bondState.retry}
/>
);
@@ -1,7 +1,6 @@
import type { FC } from "react";
import {
Alert,
Badge,
Group,
Skeleton,
@@ -137,8 +136,6 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
<InstrumentHeader
logoSrc={vm.logoSrc}
name={bond.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle}
>
@@ -156,20 +153,8 @@ const BondPageView: FC<BondPageViewProps> = (props) => {
)}
</Group>
{!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
{quoteContent}
{!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
<InstrumentTerms title={t("bond.terms")}>
<InstrumentMetrics>
+8 -14
View File
@@ -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:
+4 -6
View File
@@ -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();
},
};
}
+16 -19
View File
@@ -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: "Добавить операцию" }),
+2 -22
View File
@@ -75,7 +75,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
{t("bank.back")}
</Button>
{!vm.notFound && <Button onClick={vm.retryCard}>{t("bank.retry")}</Button>}
</Group>
)}
error
@@ -125,27 +124,16 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</div>
{!!vm.archiveError && (
<BankFeedback error={vm.archiveError} retry={vm.retryCard} />
<BankFeedback error={vm.archiveError} />
)}
{card.archivedAt && <Text>{t("bank.archiveHint")}</Text>}
</Stack>
{vm.cardRefreshError && (
<Group role="alert">
<Text>{t("bank.refreshError")}</Text>
<Button onClick={vm.retryCard}>{t("bank.retry")}</Button>
</Group>
)}
{vm.cardRefreshing && <Text role="status">{t("bank.refreshing")}</Text>}
{vm.cardsFeedback && (
<BankFeedback
{...vm.cardsFeedback}
loadingError
retry={vm.retryCards}
/>
)}
@@ -187,7 +175,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
</Group>
<Stack
aria-busy={vm.history.refreshing}
className={classes.operations}
data-pending={vm.history.changingPage || undefined}
gap="lg"
@@ -196,13 +183,7 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
<CardHistory history={vm.history} period={vm.period} rows={vm.rows} />
</Stack>
{vm.history.error && (
<Group role="alert">
<Text>{t(vm.rows ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={vm.history.retry}>{t("bank.retry")}</Button>
</Group>
)}
{vm.history.error && !vm.rows && <Text role="alert">{t("bank.error")}</Text>}
{vm.history.page !== undefined && vm.pageSize !== "all" && (
<Group
@@ -213,7 +194,6 @@ const CardPageView: FC<CardPageViewProps> = (props) => {
ref={paginationRef}
>
<Pagination
disabled={vm.history.refreshing}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={onPageChange}
@@ -115,8 +115,6 @@ function useCardPageVm() {
error: history.isError,
loading: history.isLoading,
page: history.data?.page,
refreshing: history.isFetching,
retry: () => 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;
@@ -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_) {
@@ -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_) {
+17 -18
View File
@@ -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();
});
});
@@ -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<CardsPageViewProps> = (props) => {
<CardsPageViewCards {...props} />
{vm.cards.isError && (
<Group role="alert">
<Text>{t(vm.items ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={vm.retryCards} variant="default">
{t("bank.retry")}
</Button>
</Group>
)}
{vm.cards.data && vm.cards.isFetching && <Text role="status">{t("bank.refreshing")}</Text>}
<Group align="flex-end" justify="space-between">
<BankPeriodPicker onChange={vm.setPeriod} period={vm.period} />
@@ -140,17 +126,7 @@ const CardsPageView: FC<CardsPageViewProps> = (props) => {
error={vm.summary.isError}
fetching={vm.summary.isFetching}
loading={vm.summary.isLoading}
retry={vm.retrySummary}
/>
{vm.categories.isError && (
<BankFeedback
cached={Boolean(vm.categories.data)}
error={vm.categories.error}
loadingError
retry={vm.retryCategories}
/>
)}
</Stack>
);
};
@@ -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] });
},
+11 -80
View File
@@ -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 });
@@ -15,7 +15,6 @@ const StockPage: FC = () => {
code={vm.code}
kind="stock"
loading={vm.stockState.loading}
retry={vm.stockState.retry}
/>
);
@@ -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<StockPageViewProps> = (props) => {
<InstrumentHeader
logoSrc={vm.logoSrc}
name={stock.name}
onRefresh={vm.refresh}
refreshing={vm.refreshing}
subtitle={subtitle}
>
@@ -53,12 +48,6 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
</Text>
)}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
<StockQuote quote={vm.quote} quoteState={vm.quoteState} />
<StockCompany
+8 -14
View File
@@ -42,7 +42,7 @@ function useStockPageVm() {
const { operations } = history;
const sync = useScreenSync(
useScreenSync(
{
accountId: "all",
candlePeriod: period,
@@ -56,16 +56,16 @@ function useStockPageVm() {
return {
candles: candles.data,
candlesState: section(candles, sync.retry),
candlesState: section(candles),
canonicalTo:
stock.data?.slug && stock.data.slug !== stockId
? `/stocks/${encodeURIComponent(stock.data.slug)}${search}`
: null,
code: apiErrorCode(stock.error),
company: company.data,
companyState: section(company, sync.retry),
companyState: section(company),
dividends: dividends.data,
dividendsState: section(dividends, sync.retry),
dividendsState: section(dividends),
eventRows: toInstrumentEventRows(operations.data?.items, stock.data),
filter,
hasPrevious: history.hasPrevious,
@@ -73,10 +73,7 @@ function useStockPageVm() {
onNext: history.onNext,
onPrevious: history.onPrevious,
operations: operations.data,
operationsState: {
...section(operations, sync.retry),
retry: () => 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")
@@ -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<StockCompanyProps> = (props) => {
<Stack gap="lg">
{props.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={props.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
<Text size="sm">{t("stock.partialCompany")}</Text>
</Alert>
)}
@@ -82,12 +82,6 @@ const StockQuote: FC<StockQuoteProps> = (props) => {
{t("stock.partialQuote")}
</Text>
)}
{!!props.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}
@@ -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();
},
};
}
+198 -1
View File
@@ -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);
});
});
+174 -55
View File
@@ -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,13 +34,11 @@ type AuthHandlers = {
refresh: () => Promise<boolean>;
};
const inFlight = new Set<{ invalidated: boolean; pathname: string }>();
const latestReads = new Map<string, { pending: number; sequence: number }>();
function invalidateApiRequests(isMatches: (pathname: string) => boolean) {
for (const request of inFlight)
if (isMatches(request.pathname)) request.invalidated = true;
}
type ReadAttempt = {
problem?: unknown;
reason: "abort" | "transient";
response?: Response;
};
const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
hasSession: false,
@@ -37,49 +46,11 @@ const sessionState: { handlers?: AuthHandlers; hasSession: boolean } = {
const authSessionListeners = new Set<() => void>();
function abortError() {
return new DOMException("Request aborted", "AbortError");
}
async function authenticatedFetch(request: Request): Promise<Response> {
const url = new URL(request.url);
url.searchParams.delete("sync");
url.searchParams.sort();
const key = `${request.method} ${url}`;
const ticket = { invalidated: false, pathname: url.pathname };
const read
= request.method === "GET"
? (latestReads.get(key) ?? { pending: 0, sequence: 0 })
: undefined;
if (read) {
read.sequence += 1;
read.pending += 1;
latestReads.set(key, read);
}
const sequence = read?.sequence;
inFlight.add(ticket);
try {
return await fetchCurrent(
request,
() => !ticket.invalidated && (!read || sequence === read.sequence),
);
}
finally {
inFlight.delete(ticket);
if (read && --read.pending === 0) latestReads.delete(key);
}
}
function configureAuthClient(handlers?: AuthHandlers) {
if (!handlers) invalidateApiRequests(() => true);
sessionState.handlers = handlers;
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
}
async function fetchCurrent(
request: Request,
isLatest: () => boolean,
): Promise<Response> {
const isAuthRequest = new URL(request.url).pathname.startsWith(
"/api/v1/auth/",
);
@@ -91,7 +62,7 @@ async function fetchCurrent(
(session?.generation
? generation === session.generation()
&& generation === sessionState.handlers?.generation?.()
: session === sessionState.handlers) && isLatest();
: session === sessionState.handlers);
const token = session?.accessToken();
@@ -106,8 +77,7 @@ async function fetchCurrent(
= token && !isAuthRequest ? withToken(request, token) : request;
const retryRequest = authorized.clone();
const response = await fetch(authorized);
await response.clone().arrayBuffer();
const response = await fetchAppRead(authorized, isAuthRequest);
if (!isAuthRequest && !isCurrent())
throw new DOMException("Session changed", "AbortError");
if (
@@ -138,17 +108,154 @@ async function fetchCurrent(
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
const refreshedToken = sessionState.handlers.accessToken();
if (!refreshedToken) return response;
const retried = await fetch(withToken(retryRequest, refreshedToken));
await retried.clone().arrayBuffer();
const retried = await fetchAppRead(
withToken(retryRequest, refreshedToken),
isAuthRequest,
);
if (!isCurrent()) throw new DOMException("Session changed", "AbortError");
return retried;
}
function configureAuthClient(handlers?: AuthHandlers) {
sessionState.handlers = handlers;
setAuthenticatedSession(Boolean(sessionState.handlers?.accessToken()));
}
async function fetchAppRead(
request: Request,
isAuthRequest: boolean,
): Promise<Response> {
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<ReadAttempt> {
const controller = new AbortController();
const failure: { reason?: "abort" | "timeout" } = {};
const { promise: deadline, reject: rejectDeadline }
= Promise.withResolvers<never>();
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;
@@ -161,6 +268,19 @@ function subscribeAuthenticatedSession(listener: () => void) {
return () => authSessionListeners.delete(listener);
}
function waitForRetry(ms: number, signal: AbortSignal): Promise<void> {
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<paths>({
baseUrl: apiBaseUrl,
credentials: "include",
@@ -174,6 +294,5 @@ export {
apiQueryClient,
configureAuthClient,
hasAuthenticatedSession as getAuthenticatedSessionSnapshot,
invalidateApiRequests,
subscribeAuthenticatedSession,
};
+1 -12
View File
@@ -915,12 +915,11 @@ export interface components {
InvestmentSyncDto: {
id: string | null;
/** @enum {string} */
state: "idle" | "queued" | "running" | "failed";
state: "idle" | "running" | "failed";
errorCode: string | null;
};
InvestmentDependencyDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
@@ -934,7 +933,6 @@ export interface components {
};
InvestmentResourceMetaDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
@@ -1126,7 +1124,6 @@ export interface components {
};
IncomeDependencyDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
@@ -1142,7 +1139,6 @@ export interface components {
};
IncomeAnalyticsMetaDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
@@ -1622,11 +1618,6 @@ export interface components {
/** Format: date-time */
now: string;
};
TbankSyncProgressDto: {
completed: number;
total: number;
discovering: boolean;
};
TbankSyncStatusDto: {
/** Format: uuid */
id: string;
@@ -1636,8 +1627,6 @@ export interface components {
/** @enum {string} */
state: "running" | "succeeded" | "failed" | "cancelled";
errorCode: string | null;
progress: components["schemas"]["TbankSyncProgressDto"];
resources: components["schemas"]["InvestmentResourceMetaDto"][];
};
};
responses: never;
-1
View File
@@ -6,7 +6,6 @@ export {
apiClient,
apiQueryClient,
configureAuthClient,
invalidateApiRequests,
} from "./client";
export type { paths } from "./generated/schema";
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
+31 -33
View File
@@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
@@ -37,7 +37,7 @@ function auth() {
}
describe("persistent resource query lifecycle", () => {
it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
it("observes once into the canonical cache without another import", async () => {
auth();
const modes: (null | string)[] = [];
let observations = 0;
@@ -51,7 +51,7 @@ describe("persistent resource query lifecycle", () => {
return HttpResponse.json(
resource(snapshot, {
stateVersion: observations > 1 ? "new" : "old",
dataRevision: observations > 0 ? "new" : "old",
}),
);
},
@@ -60,16 +60,16 @@ describe("persistent resource query lifecycle", () => {
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(() => usePrivateResource("a"), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await act(() => result.current.observe("new"));
await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
await act(() => result.current.observe());
await waitFor(() => expect(result.current.meta?.dataRevision).toBe("new"));
expect(modes).toEqual([null, "observe", "observe"]);
expect(modes).toEqual([null, "observe"]);
expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
expect(
queryClient.getQueryData(resourceOptions("a").queryKey),
).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } });
).toMatchObject({ data: snapshot, meta: { dataRevision: "new" } });
});
it("does not convert missing or failed source data to empty financial success", async () => {
it("keeps successful HTTP state distinct from missing or failed source data", async () => {
auth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
@@ -91,24 +91,17 @@ describe("persistent resource query lifecycle", () => {
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(false);
expect(result.current.status).toBe("pending");
expect(result.current.isPending).toBe(true);
expect(result.current.isSuccess).toBe(true);
expect(result.current.status).toBe("success");
expect(result.current.isPending).toBe(false);
expect(result.current.isLoading).toBe(false);
expect(result.current.transport).toEqual(
{
error: null,
fetchStatus: "idle",
status: "success",
},
);
expect(result.current.meta?.availability).toBe("missing");
});
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
auth();
let isReady = false;
const refreshed = resource(snapshot, { stateVersion: "ready" });
const refreshed = resource(snapshot, { dataRevision: "ready" });
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(isReady ? refreshed : resource(null)),
@@ -120,7 +113,7 @@ describe("persistent resource query lifecycle", () => {
});
await waitFor(() =>
expect(result.current.transport.status).toBe("success"),
expect(result.current.status).toBe("success"),
);
await act(async () => {
const missing = await result.current.refetch();
@@ -140,35 +133,40 @@ describe("persistent resource query lifecycle", () => {
expect(result.current.isSuccess).toBe(true);
expect(result.current.isPending).toBe(false);
});
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
it("keeps the observed snapshot when an older query finishes later", async () => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
let isStarted = false;
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/portfolio",
async ({ request }) => {
if (!new URL(request.url).searchParams.has("sync")) await blocked;
if (!new URL(request.url).searchParams.has("sync")) {
isStarted = true;
await blocked;
return HttpResponse.json(resource({ ...snapshot, isStale: true }));
}
return HttpResponse.json(resource(snapshot));
},
),
);
const old = apiClient.GET(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "a" } } },
);
// eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below.
const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
params: { path: { accountId: "a" }, query: { sync: "observe" } },
const { result } = renderHook(() => usePrivateResource("a"), {
wrapper: createQueryWrapper().wrapper,
});
release();
await rejected;
await waitFor(() => expect(isStarted).toBe(true));
await act(() => result.current.observe());
await act(async () => {
release();
await blocked;
});
expect(result.current.data).toEqual(snapshot);
});
it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
auth();
+21 -75
View File
@@ -3,7 +3,6 @@ import {
type QueryFunctionContext,
useQueryClient,
type UseQueryOptions,
type UseQueryResult,
} from "@tanstack/react-query";
import { useEffect } from "react";
@@ -13,8 +12,6 @@ import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
const accessEpochs = new WeakMap<QueryClient, Map<string, number>>();
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
type Resource = {
@@ -50,9 +47,6 @@ function invalidateResourceAccess(
].every(code => !isApiErrorCode(errorCode, code))
)
return;
const epochs = accessEpochs.get(client) ?? new Map<string, number>();
accessEpochs.set(client, epochs);
epochs.set(group, (epochs.get(group) ?? 0) + 1);
const affected = {
predicate: (cached: { meta?: Record<string, unknown> }) =>
@@ -81,33 +75,6 @@ function observationKey<K extends ResourceQueryKey>(key: K): K {
}] as unknown as K;
}
function unwrapResourceResult<T extends Resource, E>(
result: UseQueryResult<T, E>,
) {
const isMissing = result.isSuccess && result.data.data === null;
const data: T["data"] | undefined = result.data?.data;
const meta: T["meta"] | undefined = result.data?.meta;
const status: UseQueryResult<T, E>["status"] = isMissing
? "pending"
: result.status;
// Project resource fields; refetch still returns the transport envelope.
return {
...result,
data: data ?? undefined,
isPending: result.isPending || isMissing,
isSuccess: result.isSuccess && !isMissing,
meta,
status,
transport: {
error: result.error,
fetchStatus: result.fetchStatus,
status: result.status,
},
};
}
// Observation changes transport mode, never the generated cache identity.
function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
options: UseQueryOptions<T, E, T, K>,
@@ -119,35 +86,13 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
? options.meta.accessGroup
: undefined;
let epochs = accessEpochs.get(client);
if (!epochs) {
epochs = new Map();
accessEpochs.set(client, epochs);
}
const groupEpochs = epochs;
const queryFunction = options.queryFn;
const guarded
= typeof queryFunction === "function"
? async (context: QueryFunctionContext<K>) => {
const epoch = group ? (groupEpochs.get(group) ?? 0) : 0;
const result = await queryFunction(context);
if (group && epoch !== (groupEpochs.get(group) ?? 0))
throw new DOMException("Access changed", "AbortError");
return result;
}
: queryFunction;
const query = useAuthenticatedQuery({ ...options, queryFn: guarded });
const query = useAuthenticatedQuery(options);
useEffect(() => {
if (group) invalidateResourceAccess(client, group, query.error);
}, [client, group, query.error]);
const observe = async (expectedStateVersion?: string) => {
const queryFunction = guarded;
const observe = async () => {
const queryFunction = options.queryFn;
if (
typeof queryFunction !== "function"
|| !query.isEnabled
@@ -162,33 +107,34 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
queryFn: async (context: QueryFunctionContext<K>) => {
const queryKey = observationKey(context.queryKey);
const response = await queryFunction({ ...context, queryKey });
if (
expectedStateVersion
&& response.meta.stateVersion !== expectedStateVersion
) {
// One more observational read; never turn a stale notification into an import.
const current = await queryFunction({ ...context, queryKey });
if (current.meta.stateVersion !== expectedStateVersion)
throw new DOMException("Resource changed", "AbortError");
return current;
}
return response;
return queryFunction({ ...context, queryKey });
},
retry: false,
// Observation validates raw resource metadata, before any observer projection.
// Keep the cache envelope independent of observer projection.
select: undefined,
staleTime: 0,
});
};
const data: T["data"] | undefined = query.data?.data;
const meta: T["meta"] | undefined = query.data?.meta;
// Project resource fields; refetch still returns the transport envelope.
return {
...unwrapResourceResult(query),
data: data ?? undefined,
error: query.error,
fetchStatus: query.fetchStatus,
isEnabled: query.isEnabled,
isError: query.isError,
isFetching: query.isFetching,
isLoading: query.isLoading,
isPending: query.isPending,
isSuccess: query.isSuccess,
meta,
observe,
queryKey: options.queryKey,
refetch: query.refetch,
status: query.status,
};
}
+1 -48
View File
@@ -5947,7 +5947,6 @@
"type": "string",
"enum": [
"idle",
"queued",
"running",
"failed"
]
@@ -5969,9 +5968,6 @@
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
@@ -6008,7 +6004,6 @@
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
@@ -6022,9 +6017,6 @@
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
@@ -6067,7 +6059,6 @@
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
@@ -6912,9 +6903,6 @@
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
@@ -6959,7 +6947,6 @@
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
@@ -6974,9 +6961,6 @@
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
@@ -7020,7 +7004,6 @@
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
@@ -9017,25 +9000,6 @@
"now"
]
},
"TbankSyncProgressDto": {
"type": "object",
"properties": {
"completed": {
"type": "number"
},
"total": {
"type": "number"
},
"discovering": {
"type": "boolean"
}
},
"required": [
"completed",
"total",
"discovering"
]
},
"TbankSyncStatusDto": {
"type": "object",
"properties": {
@@ -9087,15 +9051,6 @@
"errorCode": {
"type": "string",
"nullable": true
},
"progress": {
"$ref": "#/components/schemas/TbankSyncProgressDto"
},
"resources": {
"type": "array",
"items": {
"$ref": "#/components/schemas/InvestmentResourceMetaDto"
}
}
},
"required": [
@@ -9103,9 +9058,7 @@
"scope",
"now",
"state",
"errorCode",
"progress",
"resources"
"errorCode"
]
}
}
+7
View File
@@ -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 временно недоступен. Повторите попытку позже.",
},
+15 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
import {
compareDateDesc,
defaultPeriod,
isValidPeriod,
moscowDate,
} from "./calendar";
import { calendarDate } from "./calendar.schema";
describe("calendar dates", () => {
@@ -33,6 +38,15 @@ describe("calendar dates", () => {
expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01");
});
it("defaults to the last 7 days including today", () => {
const [from, to] = defaultPeriod();
expect(calendarDate.safeParse(from).success).toBe(true);
expect(to).toBe(moscowDate(new Date()));
expect((Date.parse(to) - Date.parse(from)) / 86_400_000).toBe(6);
expect(from <= to).toBe(true);
});
it("compares dates newest first and keeps dateless rows last", () => {
const dates = [null, "2026-01-01", "2026-03-01", "2026-02-01"];
+12 -1
View File
@@ -9,6 +9,9 @@ function isValidPeriod(from: null | string, to: null | string) {
);
}
const DAY_MS = 86_400_000;
const DEFAULT_PERIOD_DAYS = 6;
const moscowFormatter = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
@@ -22,6 +25,14 @@ function compareDateDesc(a: null | string, b: null | string) {
return (b ?? "").localeCompare(a ?? "");
}
// Last 7 days including today. An empty period always means this range.
function defaultPeriod(): [string, string] {
const to = new Date();
const from = new Date(to.getTime() - DEFAULT_PERIOD_DAYS * DAY_MS);
return [moscowDate(from), moscowDate(to)];
}
function moscowDate(value: Date): string {
const parts = moscowFormatter.formatToParts(value);
@@ -31,4 +42,4 @@ function moscowDate(value: Date): string {
return `${part("year")}-${part("month")}-${part("day")}`;
}
export { compareDateDesc, isValidPeriod, moscowDate };
export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate };
+2 -1
View File
@@ -1,4 +1,4 @@
export { compareDateDesc, isValidPeriod, moscowDate } from "./calendar";
export { compareDateDesc, defaultPeriod, isValidPeriod, moscowDate } from "./calendar";
export { calendarDate } from "./calendar.schema";
export {
formatChartValue,
@@ -8,4 +8,5 @@ export {
formatSnapshotTime,
instrumentLogoUrl,
} from "./display";
export { useInitialAvailability } from "./initial-availability";
export { useUrlPeriod } from "./useUrlPeriod";
@@ -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();
}
});
});
+49
View File
@@ -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 | string>(
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 { useInitialAvailability };
+13 -4
View File
@@ -1,13 +1,22 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { isValidPeriod } from "./calendar";
import { defaultPeriod, isValidPeriod } from "./calendar";
function useUrlPeriod() {
const [parameters, setParameters] = useSearchParams();
const from = parameters.get("from");
const to = parameters.get("to");
const isValid = isValidPeriod(from, to);
const hasPeriod = from !== null || to !== null;
const [[defaultFrom, defaultTo]] = useState<[string, string]>(defaultPeriod);
const resolve = (value: null | string, fallback: string) => {
if (!isValid) return null;
return hasPeriod ? value : fallback;
};
useEffect(() => {
if (isValid || (!parameters.has("from") && !parameters.has("to"))) return;
const normalized = new URLSearchParams(parameters);
@@ -17,10 +26,10 @@ function useUrlPeriod() {
}, [isValid, parameters, setParameters]);
return {
from: isValid ? from : null,
from: resolve(from, defaultFrom),
parameters,
setParameters,
to: isValid ? to : null,
to: resolve(to, defaultTo),
};
}

Some files were not shown because too many files have changed in this diff Show More