diff --git a/.ai/architecture.md b/.ai/architecture.md
index 877b55d..592142c 100644
--- a/.ai/architecture.md
+++ b/.ai/architecture.md
@@ -51,31 +51,40 @@ Cross-slice consumer использует `@/` и `index.ts` владельца,
композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые.
-Направление зависимостей и public imports частично проверяет
-`npm run deps`; Knip проверяет использование exports, а не смысл placement.
+Направление зависимостей, public imports и запрет соседних slices в
+`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
+одного slice и связи shared segments этот запрет не ограничивает.
+Knip проверяет использование exports, а не смысл placement.
## UI foundation
-`shared/theme/tokens.json` — переносимый source design tokens; `theme.ts`
+Private workspace `packages/tokens` экспортирует переносимый source design tokens
+как `@wealthledger/tokens/tokens.json`. Private workspace `packages/ui`
адаптирует доверенный JSON к Mantine без runtime schema. App и Storybook читают
-public API `shared/theme`. Storybook показывает synthetic presentation states,
+`@wealthledger/ui/theme` и явно подключают `@wealthledger/ui/styles.css` после
+Mantine CSS. Потребители используют public exports пакетов напрямую. Storybook показывает synthetic presentation states,
не использует application API. RTL/MSW проверяют поведение приложения отдельно.
-Existing route-aware AppShell расположен в shared/ui; это legacy placement,
-а не образец нового shared primitive. При изменении foundation прочитай
+Route-aware AppShell и его navigation styles принадлежат `app/ui`;
+`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
+Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private
+`packages/icons`; package не зависит от UI, domain или motion.
+`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package
+Add/Edit/Delete; restore/category SVG остаются локальными.
+
## Примеры
-- Общая busy-область: существующий [LoadingSkeleton](../src/shared/ui/LoadingSkeleton.tsx), а не domain widget в shared.
-- Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage.tsx) и её flat `components/*.view.tsx`; новый page-local filter остаётся здесь.
+- Общая busy-область: существующий [LoadingSkeleton](../packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx), а не domain widget в shared.
+- Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx) и её `components/`; новый page-local filter остаётся здесь. Комплекты файлов группируются по [UI convention](conventions/ui.md#размещение-файлов-компонента).
- Portfolio query: [entity public API](../src/entities/portfolio/index.ts); новая UI-сводка с бизнес-смыслом не становится shared только из-за нескольких экранов.
## Проверка
После placement/import change: `npm run deps`, `npm run typecheck`, `npm run knip`;
после собственного behavior — применимые tests. Финальный gate указан в AGENTS.
-Existing dependency config не запрещает все same-layer imports: их и смысл
-owner проверяет review. Не объявляй Steiger установленным; graph проверяет
-текущий dependency-cruiser. Review отдельно проверяет смысл owner: разрешённый dependency graph не доказывает
-правильный выбор слоя.
+Graph `src` и `packages` проверяет текущий dependency-cruiser; зависимости
+из packages в код приложения `src` запрещены. Generated `packages/*/dist`
+не анализируется как исходник; все package sources остаются в graph. Review отдельно проверяет смысл
+owner: разрешённый dependency graph не доказывает правильный выбор слоя.
diff --git a/.ai/conventions/storybook.md b/.ai/conventions/storybook.md
index 62fb6b9..519437f 100644
--- a/.ai/conventions/storybook.md
+++ b/.ai/conventions/storybook.md
@@ -1,5 +1,8 @@
# Storybook
+Единый каталог включает stories приложения и `packages/*/src`; общие
+StateView/LoadingSkeleton и их stories принадлежат `packages/ui`.
+
## Когда нужна story
- Shared UI/theme: показывай принятые visual roles и переиспользуемое поведение.
@@ -23,7 +26,7 @@ Stories не достигают application API, auth, VM и query providers д
контракт: keyboard activation, доступность действия, local callback. Не повторяй
RTL suite и не тестируй внутренности Mantine. Существующий
[Button](../../src/shared/ui/Button.stories.tsx) показывает themed control
-без production wrapper; [LoadingSkeleton](../../src/shared/ui/LoadingSkeleton.stories.tsx)
+без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx)
— значимые busy states.
`npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют
diff --git a/.ai/conventions/testing.md b/.ai/conventions/testing.md
index 740656a..ab0b1af 100644
--- a/.ai/conventions/testing.md
+++ b/.ai/conventions/testing.md
@@ -45,7 +45,7 @@ Finance: loading/error/empty/loaded, cached snapshot при refetch failure, ret
отсутствие старых данных после session/connection switch. ErrorBoundary: test-only
throwing route, безопасная ссылка и reset после navigation.
-Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm.test.tsx),
+Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts),
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
diff --git a/.ai/conventions/ui.md b/.ai/conventions/ui.md
index badd161..c1ead96 100644
--- a/.ai/conventions/ui.md
+++ b/.ai/conventions/ui.md
@@ -12,8 +12,11 @@ Mantine component и общую тему. Для выпадающего выбо
Семантический HTML для структуры страницы остаётся уместным.
Icons ищи у существующего owner (например,
-[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon.tsx));
-общая icon library сейчас не принята. Не добавляй пакет ради одного символа. Business/domain
+[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx));
+статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
+по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
+`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
+и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
components живут у feature/entity/page, даже при повторном использовании.
Wrapper нужен для проектной ответственности, а не ради нового имени
@@ -53,11 +56,34 @@ retry callbacks, domain grouping и derived presentation data. View получа
image fallback) и formatter одного переданного значения. Группировка/доли
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
-Отдельный state/section с самостоятельной responsibility выноси в flat
-`components/*.view.tsx`. Небольшой tightly-coupled renderer оставляй рядом.
-Не создавай folder/barrel/test/CSS для каждого view и не дроби по line count.
-Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage.tsx)
-и [его VM](../../src/pages/account-income/ui/AccountIncomePage.vm.ts).
+Отдельный state/section с самостоятельной responsibility выноси в
+`components/` своего component. Небольшой tightly-coupled renderer оставляй
+рядом; размер файла сам по себе не требует разделения.
+Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx)
+и [его VM](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts).
+
+## Размещение файлов компонента
+
+Правило действует в `src` и `packages`: компонент с сопутствующими файлами
+(view, VM, types, CSS, tests, stories) размещай в папке `Component/`.
+Сохраняй полные имена файлов: `Component.tsx`, `Component.view.tsx`,
+`Component.test.tsx`. Создавай только нужные файлы; одиночный компонент
+оставляй непосредственно в `ui`, `components` или source-каталоге пакета.
+
+Внутренние части принадлежат папке родителя; для набора state/section views
+используй её `components/`. Внутри него действует то же правило: отдельная
+папка объединяет комплект файлов, одиночный view остаётся файлом.
+Стили и хуки нескольких соседних компонентов остаются у ближайшего общего
+владельца; не копируй их и не прячь внутрь одного потребителя.
+
+Папка компонента не создаёт новую FSD-границу. Публичные exports остаются в
+существующем `index.ts` slice/segment или entry пакета; внутри используй
+прямые относительные imports без дополнительных barrels на каждый компонент.
+При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
+
+Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
+лежит рядом со своим view, тестом и story; общий `BankForms.module.css`
+остаётся в `bank-card/ui`.
## Формы
@@ -107,10 +133,29 @@ loading/error/empty/loaded и recovery по применимости. [Storybook
## Визуальная основа
-Общая тема живёт в `src/shared/theme`: `tokens.json` задаёт переносимые значения,
-`theme.ts` адаптирует их к Mantine. Используй public API темы и её semantic roles,
-без отдельной палитры в page/component. Поддерживается светлая схема.
+Private workspace `packages/tokens` владеет переносимыми значениями
+`tokens.json`; `packages/ui/src/theme.ts` читает export пакета
+`@wealthledger/tokens/tokens.json` и адаптирует его к Mantine. Используй
+`@wealthledger/ui/theme` и его semantic roles; runtime подключает
+`@wealthledger/ui/styles.css` после Mantine CSS. Providers остаются у runtime.
+Палитра page/component следует этим semantic roles. Поддерживается светлая схема.
Цвет дополняет подпись или знак; проверяй контраст конкретной пары text/background.
Warning не используй для мелкого текста на белом. Деньги сохраняют валюту, знак
и различие между нулём и неизвестной суммой. Общие состояния демонстрирует
Storybook; presentation stories используют synthetic data без application API.
+
+EntryCard, StateView и LoadingSkeleton принадлежат `packages/ui`; публичный import —
+`@wealthledger/ui`. Переводы, retry и формы skeleton остаются у callers.
+Compatibility re-exports удалены; callers используют public export пакета.
+
+## Modal animation
+
+BankCardForm, OperationForm, DeleteOperation и CategoryManager монтируют Modal
+с `opened=true`: Mantine Transition начинает в состоянии `entered`.
+CSS `modal-appear`/`modal-backdrop-appear` в `packages/ui/src/styles.css`
+обеспечивает появление при mount (220ms, только no-preference). Mantine theme
+владеет exit transition (180ms), overlay blur и completion callback;
+`onExitTransitionEnd` позволяет дождаться закрытия перед unmount и вернуть фокус.
+Удаление CSS без изменения mount lifecycle убирает вступительную анимацию.
+TbankConnection Modal использует toggle `opened`; вывод о разделении фаз
+банковских форм на этот сценарий автоматически не распространяется.
diff --git a/.dockerignore b/.dockerignore
index ec510c1..0c527df 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -13,3 +13,30 @@
!public/**
!docker/
!docker/**
+!packages/
+!packages/tokens/
+!packages/tokens/package.json
+!packages/tokens/tokens.json
+!packages/ui/
+!packages/ui/package.json
+!packages/ui/vite.config.ts
+!packages/ui/tsconfig.json
+!packages/ui/src/
+!packages/ui/src/theme.ts
+!packages/ui/src/styles.css
+!packages/ui/src/index.ts
+!packages/ui/src/StateView.tsx
+!packages/ui/src/LoadingSkeleton.tsx
+!packages/ui/src/EntryCard.tsx
+!packages/ui/src/EntryCard.module.css
+!packages/icons/
+!packages/icons/package.json
+!packages/icons/tsconfig.json
+!packages/icons/vite.config.ts
+!packages/icons/src/
+!packages/icons/src/index.ts
+!packages/icons/src/types.ts
+!packages/icons/src/svg/
+!packages/icons/src/svg/AddIcon.tsx
+!packages/icons/src/svg/EditIcon.tsx
+!packages/icons/src/svg/DeleteIcon.tsx
diff --git a/.gitea/workflows/image.yml b/.gitea/workflows/image.yml
index 67ce36c..d802cea 100644
--- a/.gitea/workflows/image.yml
+++ b/.gitea/workflows/image.yml
@@ -45,15 +45,17 @@ jobs:
run: |
VERSION="${RELEASE_TAG#v}"
if docker manifest inspect "$IMAGE:$VERSION" >/dev/null 2>manifest-error.log; then
- echo 'Refusing to overwrite an existing release tag'; exit 1
+ DIGEST="$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{.Manifest.Digest}}')"
+ else
+ if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
+ echo 'Cannot prove tag absence; refusing publication'; exit 1
+ fi
+ docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
+ DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
fi
- if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
- echo 'Cannot prove tag absence; refusing publication'; exit 1
- fi
- docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
- DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
docker pull "$IMAGE@$DIGEST"
test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$REVISION"
+ test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = "$VERSION"
docker run --rm --entrypoint sh "$IMAGE@$DIGEST" -c 'test -s /usr/share/nginx/html/index.html && nginx -t && nginx && wget -q -O /dev/null http://127.0.0.1:8080/'
printf 'Frontend image: %s@%s\n' "$IMAGE" "$DIGEST"
- name: Remove registry session
diff --git a/.storybook/dependency-cruiser.cjs b/.storybook/dependency-cruiser.cjs
index 4e93d62..a10e1d6 100644
--- a/.storybook/dependency-cruiser.cjs
+++ b/.storybook/dependency-cruiser.cjs
@@ -9,7 +9,7 @@ module.exports = {
severity: "error",
from: { path: "\\.stories\\.tsx$|^\\.storybook/preview\\.tsx$" },
to: {
- path: "^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\\.vm\\.ts$|^src/pages/[^/]+/ui/[^/]+Page\\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
+ path: "^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\\.vm\\.ts$|^src/pages/[^/]+/ui/.*Page\\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
reachable: true,
},
},
diff --git a/.storybook/main.ts b/.storybook/main.ts
index ca5d4b1..8557f9c 100644
--- a/.storybook/main.ts
+++ b/.storybook/main.ts
@@ -1,7 +1,7 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
- stories: ["../src/**/*.stories.tsx"],
+ stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
core: { disableTelemetry: true },
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index b5bfa08..acc23dc 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -1,12 +1,13 @@
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css";
+import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru";
import "../src/shared/i18n";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import type { Preview } from "@storybook/react-vite";
-import { cssVariablesResolver, theme } from "@/shared/theme";
+import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
const colorSchemeManager = {
get: () => "light" as const,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17f0c38..ddb1ae6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
# Changelog
+## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
+
+### Features
+
+* show persisted investment data and sync state ([4e5f564](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/4e5f564fa1d583013fb3e7febdb3baa9737446b4))
+
+### Bug Fixes
+
+* **api:** update validation and conflict response contract ([db184c8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/db184c8caec01609e8a3df3b16097ce634e5eff0))
+* **auth:** isolate session races and financial caches ([a209d71](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a209d715892862bb6d48084df0c84a10b8d56bf1))
+* **ci:** resume verification of immutable release images ([6a87379](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a87379813ba1d4002779b23b2a65446b4d10258))
+* **ui:** improve financial text contrast ([c86be7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c86be7bf73d0141e0606d259ada6453c0d8e3dec))
+
+### Refactoring
+
+* colocate frontend component files ([b987fd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b987fd8e780963dfe1dde0c714232697a52e0475))
+* establish local frontend workspace boundaries ([85fcfe3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/85fcfe3d073f80e759dafb14281c9462c1d7410d))
+
## [0.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.2.3...v0.3.0) (2026-09-20)
### Features
diff --git a/Dockerfile b/Dockerfile
index 88feb2d..2f3c85c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,10 +2,14 @@ FROM node:24.21.0-bookworm-slim AS dependencies
WORKDIR /app
ENV HUSKY=0
COPY package.json package-lock.json ./
+COPY packages/tokens/package.json ./packages/tokens/
+COPY packages/ui/package.json ./packages/ui/
+COPY packages/icons/package.json ./packages/icons/
RUN npm ci
FROM dependencies AS development
COPY . .
+RUN npm run build:packages
ENV VITE_API_BASE_URL="" API_PROXY_TARGET=http://backend:3000
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "8080", "--strictPort"]
diff --git a/README.md b/README.md
index e5ee4d9..94f3292 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,8 @@ staged diff с полной редакцией секретов, затем за
`Dockerfile` имеет targets `development` (Vite HMR и proxy `/api/` во внутренний backend)
и `production` (nginx на 8080, SPA fallback и same-origin API proxy). Node соответствует `.nvmrc`.
Build context ограничен `.dockerignore`; `.env`, local DB и host node_modules
-не попадают в image.
+не попадают в image. Изменение `packages/tokens` или `packages/ui` требует rebuild development
+image, если локальный запуск использует bind-mount только `src`/`public`.
```bash
docker build --target development -t wealthledger-frontend:development .
@@ -194,3 +195,33 @@ Commits и PR titles — Conventional Commits, включая `spec:`. Верс
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+
+## Локальные пакеты
+
+`packages/tokens` — private npm workspace с единственным export
+`@wealthledger/tokens/tokens.json`. `packages/ui` экспортирует собранные
+`@wealthledger/ui/theme` (JS + declarations) и `@wealthledger/ui/styles.css`.
+Корневой export `@wealthledger/ui` содержит EntryCard, StateView и LoadingSkeleton;
+Потребители импортируют компоненты напрямую из `@wealthledger/ui`.
+CSS явно подключается после Mantine; providers остаются в приложении/Storybook.
+Тема доступна через `@wealthledger/ui/theme`; compatibility re-exports удалены.
+
+JSON не требует сборки. `npm ci` связывает workspaces, а существующие команды
+разработки, проверок и сборки сначала выполняют `npm run build:packages`.
+Library build использует отдельный Vite config без app plugins и TypeScript
+для declarations. После изменения package sources перезапустите `npm run dev`
+или `npm run dev:catalog`; отдельный watch-процесс пока не нужен.
+Начальная версия manifest взята из приложения; отдельный release-процесс
+и публикация не настроены.
+
+Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon`
+и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`;
+поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion.
+React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5;
+цвет наследуется через `currentColor`, SVG props можно передать явно.
+В action используйте декоративный ``,
+доступное имя задавайте кнопке. Standalone пример:
+``.
+Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller.
+Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth.
+Restore/category SVG и предметный BankOperationIcon остаются в приложении.
diff --git a/biome.json b/biome.json
index 6eff3f8..babf97e 100644
--- a/biome.json
+++ b/biome.json
@@ -7,6 +7,7 @@
"includes": [
"**",
"!dist",
+ "!packages/*/dist",
"!storybook-static",
"!src/shared/api/generated",
"!src/shared/api/spec",
@@ -38,7 +39,8 @@
"vite.config.ts",
"vitest.config.ts",
".storybook/main.ts",
- ".storybook/preview.tsx"
+ ".storybook/preview.tsx",
+ "packages/*/vite.config.ts"
],
"linter": {
"rules": {
diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs
index a87fcea..a2d1217 100644
--- a/dependency-cruiser.cjs
+++ b/dependency-cruiser.cjs
@@ -1,5 +1,11 @@
module.exports = {
forbidden: [
+ {
+ name: "packages-must-not-depend-on-app",
+ severity: "error",
+ from: { path: "^packages/" },
+ to: { path: "^src/" },
+ },
{
name: "no-circular",
severity: "error",
@@ -17,6 +23,12 @@ module.exports = {
pathNot: "^src/$1/$2/",
},
},
+ {
+ name: "no-same-layer-cross-slice-imports",
+ severity: "error",
+ from: { path: "^src/(pages|features|entities)/([^/]+)/" },
+ to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
+ },
{
name: "shared-must-not-depend-on-layers",
severity: "error",
@@ -43,6 +55,7 @@ module.exports = {
},
],
options: {
+ exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" },
tsPreCompilationDeps: true,
diff --git a/knip.json b/knip.json
index f2b3ac7..5f5b315 100644
--- a/knip.json
+++ b/knip.json
@@ -1,4 +1,17 @@
{
- "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"],
- "ignoreDependencies": ["conventional-changelog-conventionalcommits"]
+ "ignoreDependencies": ["conventional-changelog-conventionalcommits"],
+ "workspaces": {
+ ".": {
+ "project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+ },
+ "packages/tokens": {
+ "entry": ["tokens.json"]
+ },
+ "packages/ui": {
+ "project": ["src/**/*.{ts,tsx}"]
+ },
+ "packages/icons": {
+ "project": ["src/**/*.{ts,tsx}"]
+ }
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 0caad7d..0ac4bc4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,15 @@
{
"name": "wealth-ledger-frontend",
- "version": "0.3.0",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wealth-ledger-frontend",
- "version": "0.3.0",
+ "version": "0.4.0",
+ "workspaces": [
+ "packages/*"
+ ],
"dependencies": {
"@hookform/resolvers": "5.9.1",
"@mantine/charts": "9.6.1",
@@ -14,6 +17,9 @@
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
"@tanstack/react-query": "5.102.8",
+ "@wealthledger/icons": "file:packages/icons",
+ "@wealthledger/tokens": "file:packages/tokens",
+ "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23",
"i18next": "26.4.2",
"motion": "13.2.0",
@@ -5460,6 +5466,18 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/@wealthledger/icons": {
+ "resolved": "packages/icons",
+ "link": true
+ },
+ "node_modules/@wealthledger/tokens": {
+ "resolved": "packages/tokens",
+ "link": true
+ },
+ "node_modules/@wealthledger/ui": {
+ "resolved": "packages/ui",
+ "link": true
+ },
"node_modules/@webcontainer/env": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz",
@@ -16399,6 +16417,47 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+ },
+ "packages/icons": {
+ "name": "@wealthledger/icons",
+ "version": "0.3.0",
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ },
+ "peerDependencies": {
+ "react": "19.3.0"
+ }
+ },
+ "packages/tokens": {
+ "name": "@wealthledger/tokens",
+ "version": "0.3.0"
+ },
+ "packages/ui": {
+ "name": "@wealthledger/ui",
+ "version": "0.3.0",
+ "dependencies": {
+ "@wealthledger/tokens": "0.3.0"
+ },
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "storybook": "10.6.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ },
+ "peerDependencies": {
+ "@mantine/core": "9.6.1",
+ "@mantine/hooks": "9.6.1",
+ "react": "19.3.0",
+ "react-dom": "19.3.0"
+ }
}
}
}
diff --git a/package.json b/package.json
index 9f5ecc7..b8ec252 100644
--- a/package.json
+++ b/package.json
@@ -1,33 +1,34 @@
{
"name": "wealth-ledger-frontend",
"private": true,
- "version": "0.3.0",
+ "version": "0.4.0",
"type": "module",
"engines": {
"node": "24.21.0"
},
"scripts": {
- "dev": "vite",
- "dev:catalog": "storybook dev -p 6006 --no-open --disable-telemetry",
- "build:catalog": "storybook build --output-dir storybook-static --disable-telemetry",
+ "dev": "npm run build:packages && vite",
+ "dev:catalog": "npm run build:packages && storybook dev -p 6006 --no-open --disable-telemetry",
+ "build:catalog": "npm run build:packages && storybook build --output-dir storybook-static --disable-telemetry",
"preview:catalog": "vite preview --outDir storybook-static",
- "typecheck": "tsc -b --noEmit",
- "build": "tsc -b && vite build",
+ "typecheck": "npm run build:packages && tsc -b --noEmit",
+ "build": "npm run build:packages && tsc -b && vite build",
"lint": "biome check .",
- "test": "vitest",
- "test:run": "vitest run",
- "test:coverage": "vitest run --coverage",
+ "test": "npm run build:packages && vitest",
+ "test:run": "npm run build:packages && vitest run",
+ "test:coverage": "npm run build:packages && vitest run --coverage",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "node scripts/check-openapi.mjs",
- "knip": "knip",
- "deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
- "verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
+ "knip": "npm run build:packages && knip",
+ "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
+ "verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
"prepare": "husky",
"preview": "vite preview",
- "deps:storybook": "depcruise src .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
+ "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
"release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci",
- "commitlint": "commitlint"
+ "commitlint": "commitlint",
+ "build:packages": "npm run build --workspaces --if-present"
},
"lint-staged": {
"*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched"
@@ -39,6 +40,9 @@
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
"@tanstack/react-query": "5.102.8",
+ "@wealthledger/icons": "file:packages/icons",
+ "@wealthledger/tokens": "file:packages/tokens",
+ "@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23",
"i18next": "26.4.2",
"motion": "13.2.0",
@@ -88,5 +92,8 @@
"allowScripts": {
"esbuild@0.28.2": true,
"fsevents@2.3.3": true
- }
+ },
+ "workspaces": [
+ "packages/*"
+ ]
}
diff --git a/packages/icons/package.json b/packages/icons/package.json
new file mode 100644
index 0000000..e3f8c13
--- /dev/null
+++ b/packages/icons/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@wealthledger/icons",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "sideEffects": false,
+ "scripts": {
+ "build": "vite build && tsc -p tsconfig.json"
+ },
+ "peerDependencies": {
+ "react": "19.3.0"
+ },
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ }
+}
diff --git a/packages/icons/src/Icons.stories.tsx b/packages/icons/src/Icons.stories.tsx
new file mode 100644
index 0000000..f9b21e2
--- /dev/null
+++ b/packages/icons/src/Icons.stories.tsx
@@ -0,0 +1,45 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { AddIcon, DeleteIcon, EditIcon } from "./index";
+
+const meta = {
+ title: "Design System/Icons",
+ component: AddIcon,
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Статичные SVG: size по умолчанию 24, strokeWidth 1.5, цвет currentColor. В кнопке иконка декоративная, имя принадлежит действию. Для самостоятельного изображения задайте aria-hidden={false}, role=img и aria-label либо aria-labelledby. Геометрия взята из Card/Cards; подписи и действия остаются у приложения.",
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Decorative: Story = {
+ render: () => (
+
+ ),
+};
+
+export const Meaningful: Story = {
+ render: () => (
+
+ ),
+};
diff --git a/packages/icons/src/Icons.test.tsx b/packages/icons/src/Icons.test.tsx
new file mode 100644
index 0000000..31c48ef
--- /dev/null
+++ b/packages/icons/src/Icons.test.tsx
@@ -0,0 +1,34 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { AddIcon, DeleteIcon, EditIcon } from "./index";
+
+describe("icon accessibility", () => {
+ it.each([AddIcon, EditIcon, DeleteIcon])(
+ "keeps decoration hidden and exposes explicitly named images: %s",
+ (Icon) => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.queryByRole("img")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
+ ).toHaveAttribute("focusable", "false");
+ rerender(
+ ,
+ );
+ const icon = screen.getByRole("img", { name: "Пример" });
+ expect(icon).toHaveAttribute("width", "18");
+ expect(icon).toHaveAttribute("height", "18");
+ expect(icon).toHaveAttribute("stroke-width", "2");
+ expect(icon).toHaveAttribute("stroke", "currentColor");
+ },
+ );
+});
diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts
new file mode 100644
index 0000000..296d458
--- /dev/null
+++ b/packages/icons/src/index.ts
@@ -0,0 +1,4 @@
+export { AddIcon } from "./svg/AddIcon";
+export { DeleteIcon } from "./svg/DeleteIcon";
+export { EditIcon } from "./svg/EditIcon";
+export type { IconProps } from "./types";
diff --git a/packages/icons/src/svg/AddIcon.tsx b/packages/icons/src/svg/AddIcon.tsx
new file mode 100644
index 0000000..cbd7ff5
--- /dev/null
+++ b/packages/icons/src/svg/AddIcon.tsx
@@ -0,0 +1,24 @@
+import type { FC } from "react";
+import type { IconProps } from "../types";
+
+const AddIcon: FC = (props) => {
+ const { size = "24", ...svgProps } = props;
+ return (
+
+ );
+};
+
+export { AddIcon };
diff --git a/packages/icons/src/svg/DeleteIcon.tsx b/packages/icons/src/svg/DeleteIcon.tsx
new file mode 100644
index 0000000..6e6321b
--- /dev/null
+++ b/packages/icons/src/svg/DeleteIcon.tsx
@@ -0,0 +1,25 @@
+import type { FC } from "react";
+import type { IconProps } from "../types";
+
+const DeleteIcon: FC = (props) => {
+ const { size = "24", ...svgProps } = props;
+ return (
+
+ );
+};
+
+export { DeleteIcon };
diff --git a/packages/icons/src/svg/EditIcon.tsx b/packages/icons/src/svg/EditIcon.tsx
new file mode 100644
index 0000000..d2c4940
--- /dev/null
+++ b/packages/icons/src/svg/EditIcon.tsx
@@ -0,0 +1,25 @@
+import type { FC } from "react";
+import type { IconProps } from "../types";
+
+const EditIcon: FC = (props) => {
+ const { size = "24", ...svgProps } = props;
+ return (
+
+ );
+};
+
+export { EditIcon };
diff --git a/packages/icons/src/types.ts b/packages/icons/src/types.ts
new file mode 100644
index 0000000..7f106f5
--- /dev/null
+++ b/packages/icons/src/types.ts
@@ -0,0 +1,10 @@
+import type { ComponentPropsWithoutRef } from "react";
+
+type IconProps = Omit<
+ ComponentPropsWithoutRef<"svg">,
+ "children" | "dangerouslySetInnerHTML"
+> & {
+ size?: number | string;
+};
+
+export type { IconProps };
diff --git a/packages/icons/tsconfig.json b/packages/icons/tsconfig.json
new file mode 100644
index 0000000..96b5e87
--- /dev/null
+++ b/packages/icons/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "rootDir": "src",
+ "outDir": "dist",
+ "jsx": "react-jsx"
+ },
+ "include": ["src/index.ts"]
+}
diff --git a/packages/icons/vite.config.ts b/packages/icons/vite.config.ts
new file mode 100644
index 0000000..9dd7242
--- /dev/null
+++ b/packages/icons/vite.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ build: {
+ lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" },
+ rolldownOptions: { external: /^react(\/|$)/ },
+ },
+});
diff --git a/packages/tokens/package.json b/packages/tokens/package.json
new file mode 100644
index 0000000..67b3554
--- /dev/null
+++ b/packages/tokens/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@wealthledger/tokens",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "files": [
+ "tokens.json"
+ ],
+ "exports": {
+ "./tokens.json": "./tokens.json"
+ }
+}
diff --git a/src/shared/theme/tokens.json b/packages/tokens/tokens.json
similarity index 99%
rename from src/shared/theme/tokens.json
rename to packages/tokens/tokens.json
index 32cab6d..4f5d284 100644
--- a/src/shared/theme/tokens.json
+++ b/packages/tokens/tokens.json
@@ -53,7 +53,7 @@
"#40c057",
"#37b24d",
"#2f9e44",
- "#2b8a3e"
+ "#237032"
],
"red": [
"#fff5f5",
@@ -151,12 +151,12 @@
},
"positive": {
"color": "green",
- "shade": 6,
+ "shade": 9,
"description": "Положительный результат; дополните знаком и текстом"
},
"negative": {
"color": "red",
- "shade": 6,
+ "shade": 9,
"description": "Ошибка / отрицательный результат; дополните текстом"
},
"warning": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
new file mode 100644
index 0000000..7f998bc
--- /dev/null
+++ b/packages/ui/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@wealthledger/ui",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./theme": {
+ "types": "./dist/theme.d.ts",
+ "import": "./dist/theme.js"
+ },
+ "./styles.css": "./dist/styles.css"
+ },
+ "sideEffects": [
+ "**/*.css"
+ ],
+ "scripts": {
+ "build": "vite build && tsc -p tsconfig.json"
+ },
+ "dependencies": {
+ "@wealthledger/tokens": "0.3.0"
+ },
+ "peerDependencies": {
+ "@mantine/core": "9.6.1",
+ "@mantine/hooks": "9.6.1",
+ "react": "19.3.0",
+ "react-dom": "19.3.0"
+ },
+ "devDependencies": {
+ "@storybook/react-vite": "10.6.0",
+ "@testing-library/react": "16.3.3",
+ "@types/react": "19.3.0",
+ "storybook": "10.6.0",
+ "typescript": "5.9.3",
+ "vite": "8.3.0",
+ "vitest": "5.0.0"
+ }
+}
diff --git a/src/shared/ui/EntryCard.module.css b/packages/ui/src/EntryCard/EntryCard.module.css
similarity index 86%
rename from src/shared/ui/EntryCard.module.css
rename to packages/ui/src/EntryCard/EntryCard.module.css
index efdcb3e..2aca6de 100644
--- a/src/shared/ui/EntryCard.module.css
+++ b/packages/ui/src/EntryCard/EntryCard.module.css
@@ -4,8 +4,8 @@
gap: var(--mantine-spacing-sm);
align-items: center;
padding: var(--mantine-spacing-md);
- border-color: var(--mantine-color-gray-3);
- background: var(--mantine-color-gray-0);
+ border-color: var(--wl-color-border);
+ background: var(--wl-color-subtle);
}
.identity,
@@ -19,15 +19,15 @@
}
.positive {
- color: var(--mantine-color-green-6);
+ color: var(--wl-color-positive);
}
.negative {
- color: var(--mantine-color-red-6);
+ color: var(--wl-color-negative);
}
.neutral {
- color: var(--mantine-color-dimmed);
+ color: var(--mantine-color-gray-7);
}
@media (max-width: 36em) {
diff --git a/packages/ui/src/EntryCard/EntryCard.stories.tsx b/packages/ui/src/EntryCard/EntryCard.stories.tsx
new file mode 100644
index 0000000..d4a2fd3
--- /dev/null
+++ b/packages/ui/src/EntryCard/EntryCard.stories.tsx
@@ -0,0 +1,37 @@
+import { Button } from "@mantine/core";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { fn } from "storybook/test";
+import { EntryCard } from "./EntryCard";
+
+const meta = {
+ title: "Design System/Components/EntryCard",
+ component: EntryCard,
+ tags: ["autodocs"],
+ args: {
+ title:
+ "Учебная запись с очень длинным названием для проверки узкого экрана",
+ metadata: "22.09.2026 · Синтетические данные",
+ initial: "У",
+ imageSrc: "data:image/png;base64,invalid",
+ value: "−1 250,00 ₽",
+ valueLabel: "Тестовое значение",
+ tone: "negative",
+ actions: (
+
+ ),
+ },
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Готовые строки и slots без финансовых вычислений. Невалидное локальное изображение показывает fallback; действие доступно с клавиатуры и на узком экране. Данные вымышлены.",
+ },
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const LongContentAndFallback: Story = {};
diff --git a/packages/ui/src/EntryCard/EntryCard.test.tsx b/packages/ui/src/EntryCard/EntryCard.test.tsx
new file mode 100644
index 0000000..213e3d1
--- /dev/null
+++ b/packages/ui/src/EntryCard/EntryCard.test.tsx
@@ -0,0 +1,35 @@
+import { MantineProvider } from "@mantine/core";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { assert, describe, expect, it } from "vitest";
+import { EntryCard } from "./EntryCard";
+
+describe("EntryCard", () => {
+ it("keeps the fallback after an image fails", () => {
+ const props = {
+ title: "Пример",
+ metadata: "Описание",
+ initial: "У",
+ value: "—",
+ valueLabel: "Неизвестно",
+ tone: "neutral" as const,
+ imageSrc: "data:image/png;base64,invalid",
+ };
+ const { container, rerender } = render(
+
+
+ ,
+ );
+ // Decorative image has no accessible name; trigger its actual failure event.
+ const image = container.querySelector("img");
+ assert(image);
+ fireEvent.error(image);
+ rerender(
+
+
+ ,
+ );
+ expect(container.querySelector("img")).toBeNull();
+ expect(screen.getByText("У")).toBeInTheDocument();
+ expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
+ });
+});
diff --git a/src/shared/ui/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx
similarity index 97%
rename from src/shared/ui/EntryCard.tsx
rename to packages/ui/src/EntryCard/EntryCard.tsx
index 7c2ad0c..455e2c4 100644
--- a/src/shared/ui/EntryCard.tsx
+++ b/packages/ui/src/EntryCard/EntryCard.tsx
@@ -25,7 +25,7 @@ const EntryCard: FC = (props) => {
style={
props.color
? {
- background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`,
+ background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
}
: undefined
}
diff --git a/src/shared/ui/LoadingSkeleton.stories.tsx b/packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx
similarity index 100%
rename from src/shared/ui/LoadingSkeleton.stories.tsx
rename to packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx
diff --git a/src/shared/ui/LoadingSkeleton.tsx b/packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx
similarity index 100%
rename from src/shared/ui/LoadingSkeleton.tsx
rename to packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx
diff --git a/src/shared/ui/StateView.stories.tsx b/packages/ui/src/StateView/StateView.stories.tsx
similarity index 100%
rename from src/shared/ui/StateView.stories.tsx
rename to packages/ui/src/StateView/StateView.stories.tsx
diff --git a/src/shared/ui/StateView.test.tsx b/packages/ui/src/StateView/StateView.test.tsx
similarity index 93%
rename from src/shared/ui/StateView.test.tsx
rename to packages/ui/src/StateView/StateView.test.tsx
index 37f34a0..352e5c0 100644
--- a/src/shared/ui/StateView.test.tsx
+++ b/packages/ui/src/StateView/StateView.test.tsx
@@ -1,7 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { LoadingSkeleton } from "./LoadingSkeleton";
+import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton";
import { StateView } from "./StateView";
describe("StateView", () => {
diff --git a/src/shared/ui/StateView.tsx b/packages/ui/src/StateView/StateView.tsx
similarity index 100%
rename from src/shared/ui/StateView.tsx
rename to packages/ui/src/StateView/StateView.tsx
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
new file mode 100644
index 0000000..6d6e67d
--- /dev/null
+++ b/packages/ui/src/index.ts
@@ -0,0 +1,3 @@
+export { EntryCard } from "./EntryCard/EntryCard";
+export { LoadingSkeleton } from "./LoadingSkeleton/LoadingSkeleton";
+export { StateView } from "./StateView/StateView";
diff --git a/src/shared/theme/styles.css b/packages/ui/src/styles.css
similarity index 100%
rename from src/shared/theme/styles.css
rename to packages/ui/src/styles.css
diff --git a/src/shared/theme/theme.test.ts b/packages/ui/src/theme.test.ts
similarity index 95%
rename from src/shared/theme/theme.test.ts
rename to packages/ui/src/theme.test.ts
index cd1dc79..e689f76 100644
--- a/src/shared/theme/theme.test.ts
+++ b/packages/ui/src/theme.test.ts
@@ -1,7 +1,7 @@
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
+import source from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest";
import { colorRoles, cssVariablesResolver, theme } from "./theme";
-import source from "./tokens.json";
describe("design theme adapter", () => {
it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => {
diff --git a/src/shared/theme/theme.ts b/packages/ui/src/theme.ts
similarity index 96%
rename from src/shared/theme/theme.ts
rename to packages/ui/src/theme.ts
index 07737b3..ba8504b 100644
--- a/src/shared/theme/theme.ts
+++ b/packages/ui/src/theme.ts
@@ -1,3 +1,4 @@
+import "./styles.css";
import {
type CSSVariablesResolver,
colorsTuple,
@@ -8,7 +9,7 @@ import {
rem,
rgba,
} from "@mantine/core";
-import designTokens from "./tokens.json";
+import designTokens from "@wealthledger/tokens/tokens.json";
const { color, typography, spacing, radius, shadows, breakpoints } =
designTokens;
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 0000000..b287378
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": ["ES2023", "DOM"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "verbatimModuleSyntax": true,
+ "declaration": true,
+ "emitDeclarationOnly": true,
+ "rootDir": "src",
+ "outDir": "dist",
+ "jsx": "react-jsx",
+ "types": ["vite/client"]
+ },
+ "include": ["src/index.ts", "src/theme.ts"]
+}
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
new file mode 100644
index 0000000..5057d03
--- /dev/null
+++ b/packages/ui/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: { index: "src/index.ts", theme: "src/theme.ts" },
+ formats: ["es"],
+ cssFileName: "styles",
+ },
+ rolldownOptions: {
+ external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
+ },
+ },
+});
diff --git a/scripts/check-image-publication.cjs b/scripts/check-image-publication.cjs
new file mode 100644
index 0000000..d0fda81
--- /dev/null
+++ b/scripts/check-image-publication.cjs
@@ -0,0 +1,75 @@
+const { test } = require("node:test");
+const assert = require("node:assert/strict");
+const {
+ mkdtempSync,
+ readFileSync,
+ writeFileSync,
+ chmodSync,
+ rmSync,
+} = require("node:fs");
+const { tmpdir } = require("node:os");
+const { join, resolve } = require("node:path");
+const EXECUTABLE_MODE = 0o755;
+const { spawnSync } = require("node:child_process");
+
+test("immutable publication resumes exact digest checks after post-push failure", () => {
+ const directory = mkdtempSync(join(tmpdir(), "wl-image-retry-"));
+ const workflow = readFileSync(
+ resolve(__dirname, "../.gitea/workflows/image.yml"),
+ "utf8",
+ );
+ const block = workflow
+ .split(" - name: Publish immutable release tag\n run: |\n")[1]
+ .split(" - name:")[0]
+ .replace(/^ {10}/gm, "");
+ const double = `#!${process.execPath}
+const fs = require('node:fs');
+const args = process.argv.slice(2).join(' ');
+fs.appendFileSync('calls', args + '\\n');
+if (args.startsWith('manifest inspect')) {
+ if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
+} else if (args.startsWith('buildx imagetools inspect')) {
+ console.log('sha256:exact');
+} else if (args.startsWith('buildx build')) {
+ fs.writeFileSync('published', 'yes');
+ fs.writeFileSync('image-metadata.json', JSON.stringify({'containerimage.digest': 'sha256:exact'}));
+} else if (args.startsWith('pull')) {
+ if (!fs.existsSync('pull-failed')) { fs.writeFileSync('pull-failed', 'yes'); process.exit(1); }
+} else if (args.startsWith('image inspect')) {
+ console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit');
+} else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
+`;
+ try {
+ for (const name of ["docker", "npm"]) {
+ writeFileSync(join(directory, name), double);
+ chmodSync(join(directory, name), EXECUTABLE_MODE);
+ }
+ const run = (extra = {}) =>
+ spawnSync("/bin/sh", ["-ec", block], {
+ cwd: directory,
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PATH: `${directory}:${process.env.PATH}`,
+ IMAGE: "synthetic/image",
+ RELEASE_TAG: "v1.2.3",
+ REVISION: "commit",
+ ...extra,
+ },
+ });
+ assert.notEqual(run().status, 0);
+ const retry = run();
+ assert.equal(retry.status, 0, retry.stdout + retry.stderr);
+ assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
+ assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
+ const calls = readFileSync(join(directory, "calls"), "utf8");
+ assert.equal(
+ calls.split("\n").filter((line) => line.startsWith("buildx build"))
+ .length,
+ 1,
+ );
+ assert.ok(calls.includes("synthetic/image@sha256:exact"));
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
diff --git a/src/app/routes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx
similarity index 84%
rename from src/app/routes/AllAccounts.test.tsx
rename to src/app/routes/AppRoutes/AllAccounts.test.tsx
index 5ef6949..f493d0a 100644
--- a/src/app/routes/AllAccounts.test.tsx
+++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx
@@ -16,6 +16,7 @@ import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
@@ -132,50 +133,56 @@ beforeEach(() => {
),
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
- accounts.map((account) => ({
- ...account,
- type: "BROKERAGE",
- openedOn: null,
- portfolioSummary: null,
- })),
+ resource(
+ accounts.map((account) => ({
+ ...account,
+ type: "BROKERAGE",
+ openedOn: null,
+ portfolioSummary: null,
+ })),
+ ),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
- HttpResponse.json(portfolio),
+ HttpResponse.json(resource(portfolio)),
),
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) =>
- HttpResponse.json({
- accountId: params.accountId,
- items,
- hasNext: false,
- nextCursor: null,
- observedAt: receipt.occurredAt,
- }),
+ HttpResponse.json(
+ resource({
+ accountId: params.accountId,
+ items,
+ hasNext: false,
+ nextCursor: null,
+ observedAt: receipt.occurredAt,
+ }),
+ ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
- HttpResponse.json(analytics),
+ HttpResponse.json(resource(analytics)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
- HttpResponse.json({
- accountId: "all",
- calculatedAt: receipt.occurredAt,
- incomeTotalsByCurrency: [],
- events: accounts.map((account) => ({
- account,
- type: "coupon",
- date: "2026-09-15",
- dateKind: "event",
- figi: null,
- instrumentUid: null,
- ticker: "BOND",
- displayName: "Облигация",
- isin: null,
- logoName: null,
- projectedAmount: { currency: "RUB", value: "2" },
- })),
- }),
+ HttpResponse.json(
+ resource({
+ accountId: "all",
+ calculatedAt: receipt.occurredAt,
+ incomeTotalsByCurrency: [],
+ events: accounts.map((account) => ({
+ account,
+ type: "coupon",
+ date: "2026-09-15",
+ dateKind: "event",
+ figi: null,
+ instrumentUid: null,
+ ticker: "BOND",
+ displayName: "Облигация",
+ isin: null,
+ logoName: null,
+ projectedAmount: { currency: "RUB", value: "2" },
+ })),
+ }),
+ ),
),
);
});
@@ -224,20 +231,22 @@ describe("All accounts routes", () => {
({ request, params }) => {
const url = new URL(request.url);
requests.push(url);
- return HttpResponse.json({
- accountId: params.accountId,
- items: [
- {
- ...receipt,
- description: url.searchParams.has("cursor")
- ? "Вторая страница"
- : "Первая страница",
- },
- ],
- hasNext: !url.searchParams.has("cursor"),
- nextCursor: url.searchParams.has("cursor") ? null : "next",
- observedAt: receipt.occurredAt,
- });
+ return HttpResponse.json(
+ resource({
+ accountId: params.accountId,
+ items: [
+ {
+ ...receipt,
+ description: url.searchParams.has("cursor")
+ ? "Вторая страница"
+ : "Первая страница",
+ },
+ ],
+ hasNext: !url.searchParams.has("cursor"),
+ nextCursor: url.searchParams.has("cursor") ? null : "next",
+ observedAt: receipt.occurredAt,
+ }),
+ );
},
),
);
@@ -274,13 +283,15 @@ describe("All accounts routes", () => {
{ code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 },
)
- : HttpResponse.json({
- accountId: "all",
- items,
- hasNext: true,
- nextCursor: "expired",
- observedAt: receipt.occurredAt,
- }),
+ : HttpResponse.json(
+ resource({
+ accountId: "all",
+ items,
+ hasNext: true,
+ nextCursor: "expired",
+ observedAt: receipt.occurredAt,
+ }),
+ ),
),
);
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
diff --git a/src/app/routes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx
similarity index 98%
rename from src/app/routes/AppRoutes.test.tsx
rename to src/app/routes/AppRoutes/AppRoutes.test.tsx
index fd796f1..6900830 100644
--- a/src/app/routes/AppRoutes.test.tsx
+++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx
@@ -1,12 +1,11 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
-
import { AuthProvider } from "@/features/auth";
-import { cssVariablesResolver, theme } from "@/shared/theme";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes/AppRoutes.tsx
similarity index 97%
rename from src/app/routes/AppRoutes.tsx
rename to src/app/routes/AppRoutes/AppRoutes.tsx
index a6c5bc9..2ebd2be 100644
--- a/src/app/routes/AppRoutes.tsx
+++ b/src/app/routes/AppRoutes/AppRoutes.tsx
@@ -1,8 +1,8 @@
import type { FC } from "react";
import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router";
-import { LazyPage } from "./RouteFallback";
-import { Protected, Public } from "./RouteGuards";
+import { LazyPage } from "../RouteFallback";
+import { Protected, Public } from "../RouteGuards";
const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card");
@@ -65,7 +65,7 @@ const SettingsPage = lazy(async () => {
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
- const { ProtectedLayout } = await import("./ProtectedLayout");
+ const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout };
});
diff --git a/src/app/routes/DocumentTitle.test.tsx b/src/app/routes/DocumentTitle/DocumentTitle.test.tsx
similarity index 95%
rename from src/app/routes/DocumentTitle.test.tsx
rename to src/app/routes/DocumentTitle/DocumentTitle.test.tsx
index 8d5579e..353cb03 100644
--- a/src/app/routes/DocumentTitle.test.tsx
+++ b/src/app/routes/DocumentTitle/DocumentTitle.test.tsx
@@ -12,6 +12,7 @@ import { MemoryRouter, useNavigate } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { ProtectedDocumentTitle } from "./DocumentTitle";
@@ -62,7 +63,7 @@ describe("document titles", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", async () => {
await accountsReady;
- return HttpResponse.json([{ id: "a", name: "ИИС" }]);
+ return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
}),
);
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
@@ -85,7 +86,7 @@ describe("document titles", () => {
async (kind, id, fallback, name) => {
server.use(
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
- HttpResponse.json({ name }),
+ HttpResponse.json(resource({ name })),
),
);
renderTitle(`/${kind}/${id}`);
diff --git a/src/app/routes/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx
similarity index 100%
rename from src/app/routes/DocumentTitle.tsx
rename to src/app/routes/DocumentTitle/DocumentTitle.tsx
diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx
index 895b5b7..f0b9e12 100644
--- a/src/app/routes/ProtectedLayout.tsx
+++ b/src/app/routes/ProtectedLayout.tsx
@@ -1,7 +1,6 @@
import type { FC } from "react";
-import { AppBreadcrumbs } from "@/app/ui";
-import { AppShell } from "@/shared/ui";
-import { ProtectedDocumentTitle } from "./DocumentTitle";
+import { AppBreadcrumbs, AppShell } from "@/app/ui";
+import { ProtectedDocumentTitle } from "./DocumentTitle/DocumentTitle";
const ProtectedLayout: FC = () => {
return (
diff --git a/src/app/routes/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx
similarity index 100%
rename from src/app/routes/RouteErrorBoundary.test.tsx
rename to src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx
diff --git a/src/app/routes/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx
similarity index 100%
rename from src/app/routes/RouteErrorBoundary.tsx
rename to src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx
diff --git a/src/app/routes/index.ts b/src/app/routes/index.ts
index 504c1bd..e3bd736 100644
--- a/src/app/routes/index.ts
+++ b/src/app/routes/index.ts
@@ -1,2 +1,2 @@
-export { AppRoutes } from "./AppRoutes";
-export { RouteErrorBoundary } from "./RouteErrorBoundary";
+export { AppRoutes } from "./AppRoutes/AppRoutes";
+export { RouteErrorBoundary } from "./RouteErrorBoundary/RouteErrorBoundary";
diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx
index 5621e02..74953f3 100644
--- a/src/app/ui/App.tsx
+++ b/src/app/ui/App.tsx
@@ -1,11 +1,11 @@
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import type { FC } from "react";
import { BrowserRouter } from "react-router";
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { AuthProvider } from "@/features/auth";
-import { cssVariablesResolver, theme } from "@/shared/theme";
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/src/app/ui/AccountBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx
similarity index 100%
rename from src/app/ui/AccountBreadcrumbs.tsx
rename to src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx
diff --git a/src/app/ui/AccountScopeSwitch.tsx b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx
similarity index 100%
rename from src/app/ui/AccountScopeSwitch.tsx
rename to src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx
diff --git a/src/app/ui/AppBreadcrumbs.module.css b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.module.css
similarity index 100%
rename from src/app/ui/AppBreadcrumbs.module.css
rename to src/app/ui/AppBreadcrumbs/AppBreadcrumbs.module.css
diff --git a/src/app/ui/AppBreadcrumbs.test.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx
similarity index 100%
rename from src/app/ui/AppBreadcrumbs.test.tsx
rename to src/app/ui/AppBreadcrumbs/AppBreadcrumbs.test.tsx
diff --git a/src/app/ui/AppBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx
similarity index 100%
rename from src/app/ui/AppBreadcrumbs.tsx
rename to src/app/ui/AppBreadcrumbs/AppBreadcrumbs.tsx
diff --git a/src/app/ui/BondBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx
similarity index 100%
rename from src/app/ui/BondBreadcrumbs.tsx
rename to src/app/ui/AppBreadcrumbs/BondBreadcrumbs.tsx
diff --git a/src/app/ui/BreadcrumbTrail.tsx b/src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx
similarity index 100%
rename from src/app/ui/BreadcrumbTrail.tsx
rename to src/app/ui/AppBreadcrumbs/BreadcrumbTrail.tsx
diff --git a/src/app/ui/CardBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx
similarity index 100%
rename from src/app/ui/CardBreadcrumbs.tsx
rename to src/app/ui/AppBreadcrumbs/CardBreadcrumbs.tsx
diff --git a/src/app/ui/StockBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx
similarity index 100%
rename from src/app/ui/StockBreadcrumbs.tsx
rename to src/app/ui/AppBreadcrumbs/StockBreadcrumbs.tsx
diff --git a/src/shared/ui/AppShell.module.css b/src/app/ui/AppShell/AppShell.module.css
similarity index 100%
rename from src/shared/ui/AppShell.module.css
rename to src/app/ui/AppShell/AppShell.module.css
diff --git a/src/shared/ui/AppShell.tsx b/src/app/ui/AppShell/AppShell.tsx
similarity index 98%
rename from src/shared/ui/AppShell.tsx
rename to src/app/ui/AppShell/AppShell.tsx
index cc2e63f..855cd29 100644
--- a/src/shared/ui/AppShell.tsx
+++ b/src/app/ui/AppShell/AppShell.tsx
@@ -2,7 +2,7 @@ import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import type { FC, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
-import { bindIconAnimation } from "../lib";
+import { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
diff --git a/src/app/ui/index.ts b/src/app/ui/index.ts
index 0f97dc8..309ceed 100644
--- a/src/app/ui/index.ts
+++ b/src/app/ui/index.ts
@@ -1 +1,2 @@
-export { AppBreadcrumbs } from "./AppBreadcrumbs";
+export { AppBreadcrumbs } from "./AppBreadcrumbs/AppBreadcrumbs";
+export { AppShell } from "./AppShell/AppShell";
diff --git a/src/entities/bank-card/index.ts b/src/entities/bank-card/index.ts
index ba1c14f..a2c908a 100644
--- a/src/entities/bank-card/index.ts
+++ b/src/entities/bank-card/index.ts
@@ -18,12 +18,13 @@ export {
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
+export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period";
-export { BankCardFace } from "./ui/BankCardFace";
-export { BankCardForm } from "./ui/BankCardForm";
+export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
+export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { BankFeedback } from "./ui/BankFeedback";
-export { BankOperationIcon } from "./ui/BankOperationIcon";
+export { BankOperationIcon } from "./ui/BankOperationIcon/BankOperationIcon";
export { BankPeriodPicker } from "./ui/BankPeriodPicker";
-export { BankSummaryView } from "./ui/BankSummary.view";
-export { CategoryManager } from "./ui/CategoryManager";
+export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
+export { CategoryManager } from "./ui/CategoryManager/CategoryManager";
export { useBankFormFocus } from "./ui/useBankFormFocus";
diff --git a/src/entities/bank-card/model/operation-kinds.ts b/src/entities/bank-card/model/operation-kinds.ts
new file mode 100644
index 0000000..e849652
--- /dev/null
+++ b/src/entities/bank-card/model/operation-kinds.ts
@@ -0,0 +1,10 @@
+import type { BankOperation } from "../api/queries";
+
+const bankOperationKinds = [
+ "expense",
+ "topup",
+ "transfer",
+ "refund",
+] as const satisfies readonly BankOperation["kind"][];
+
+export { bankOperationKinds };
diff --git a/src/entities/bank-card/ui/BankCardFace.module.css b/src/entities/bank-card/ui/BankCardFace/BankCardFace.module.css
similarity index 100%
rename from src/entities/bank-card/ui/BankCardFace.module.css
rename to src/entities/bank-card/ui/BankCardFace/BankCardFace.module.css
diff --git a/src/entities/bank-card/ui/BankCardFace.stories.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx
similarity index 100%
rename from src/entities/bank-card/ui/BankCardFace.stories.tsx
rename to src/entities/bank-card/ui/BankCardFace/BankCardFace.stories.tsx
diff --git a/src/entities/bank-card/ui/BankCardFace.tsx b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx
similarity index 97%
rename from src/entities/bank-card/ui/BankCardFace.tsx
rename to src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx
index 4ff80a1..48a0861 100644
--- a/src/entities/bank-card/ui/BankCardFace.tsx
+++ b/src/entities/bank-card/ui/BankCardFace/BankCardFace.tsx
@@ -1,7 +1,7 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
-import type { BankCard } from "../api/queries";
+import type { BankCard } from "../../api/queries";
import classes from "./BankCardFace.module.css";
type BankCardFaceProps = { card: BankCard };
diff --git a/src/entities/bank-card/ui/BankCardForm.stories.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx
similarity index 95%
rename from src/entities/bank-card/ui/BankCardForm.stories.tsx
rename to src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx
index a5580c7..513a89c 100644
--- a/src/entities/bank-card/ui/BankCardForm.stories.tsx
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormProvider, useForm } from "react-hook-form";
-import type { BankCardValues } from "../model/form-validation";
+import type { BankCardValues } from "../../model/form-validation";
import { BankCardFields } from "./BankCardForm.view";
const meta = {
diff --git a/src/entities/bank-card/ui/BankCardForm.test.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx
similarity index 99%
rename from src/entities/bank-card/ui/BankCardForm.test.tsx
rename to src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx
index d26ac14..9bec502 100644
--- a/src/entities/bank-card/ui/BankCardForm.test.tsx
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.test.tsx
@@ -6,7 +6,7 @@ import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
-import type { BankCard } from "../api/queries";
+import type { BankCard } from "../../api/queries";
import { BankCardForm } from "./BankCardForm";
const card: BankCard = {
diff --git a/src/entities/bank-card/ui/BankCardForm.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx
similarity index 93%
rename from src/entities/bank-card/ui/BankCardForm.tsx
rename to src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx
index c0c11d4..00ff33d 100644
--- a/src/entities/bank-card/ui/BankCardForm.tsx
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx
@@ -4,18 +4,18 @@ import { type FC, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/shared/lib";
-import { useBankMutations } from "../api/mutations";
-import { type BankCard, useBankCard } from "../api/queries";
+import { useBankMutations } from "../../api/mutations";
+import { type BankCard, useBankCard } from "../../api/queries";
import {
type BankCardValues,
bankCardSchema,
bankMoneyValue,
bankToday,
-} from "../model/form-validation";
+} from "../../model/form-validation";
+import { BankFeedback } from "../BankFeedback";
+import classes from "../BankForms.module.css";
+import { useBankFormFocus } from "../useBankFormFocus";
import { BankCardFields } from "./BankCardForm.view";
-import { BankFeedback } from "./BankFeedback";
-import classes from "./BankForms.module.css";
-import { useBankFormFocus } from "./useBankFormFocus";
type BankCardFormProps = { card?: BankCard; onClose: () => void };
const BankCardForm: FC = (props) => {
diff --git a/src/entities/bank-card/ui/BankCardForm.view.tsx b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx
similarity index 98%
rename from src/entities/bank-card/ui/BankCardForm.view.tsx
rename to src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx
index 1a616d8..396880a 100644
--- a/src/entities/bank-card/ui/BankCardForm.view.tsx
+++ b/src/entities/bank-card/ui/BankCardForm/BankCardForm.view.tsx
@@ -8,7 +8,7 @@ import {
bankDateInputValue,
bankDateSchema,
bankToday,
-} from "../model/form-validation";
+} from "../../model/form-validation";
type BankCardFieldsProps = { archived: boolean };
const BankCardFields: FC = (props) => {
diff --git a/src/entities/bank-card/ui/BankOperationIcon.module.css b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.module.css
similarity index 100%
rename from src/entities/bank-card/ui/BankOperationIcon.module.css
rename to src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.module.css
diff --git a/src/entities/bank-card/ui/BankOperationIcon.stories.tsx b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.stories.tsx
similarity index 97%
rename from src/entities/bank-card/ui/BankOperationIcon.stories.tsx
rename to src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.stories.tsx
index 4929acc..377c320 100644
--- a/src/entities/bank-card/ui/BankOperationIcon.stories.tsx
+++ b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.stories.tsx
@@ -1,6 +1,6 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
-import { EntryCard } from "@/shared/ui";
+import { EntryCard } from "@wealthledger/ui";
import { BankOperationIcon } from "./BankOperationIcon";
const meta = {
diff --git a/src/entities/bank-card/ui/BankOperationIcon.test.tsx b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx
similarity index 100%
rename from src/entities/bank-card/ui/BankOperationIcon.test.tsx
rename to src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx
diff --git a/src/entities/bank-card/ui/BankOperationIcon.tsx b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx
similarity index 87%
rename from src/entities/bank-card/ui/BankOperationIcon.tsx
rename to src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx
index e7ad5bc..ef02c96 100644
--- a/src/entities/bank-card/ui/BankOperationIcon.tsx
+++ b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx
@@ -1,8 +1,8 @@
import type { FC } from "react";
import { bindIconAnimation } from "@/shared/lib";
-import type { BankOperation } from "../api/queries";
+import type { BankOperation } from "../../api/queries";
-import { bankOperationAppearance } from "../model/operation-appearance";
+import { bankOperationAppearance } from "../../model/operation-appearance";
import classes from "./BankOperationIcon.module.css";
type BankOperationIconProps = {
diff --git a/src/entities/bank-card/ui/BankSummary.module.css b/src/entities/bank-card/ui/BankSummary/BankSummary.module.css
similarity index 100%
rename from src/entities/bank-card/ui/BankSummary.module.css
rename to src/entities/bank-card/ui/BankSummary/BankSummary.module.css
diff --git a/src/entities/bank-card/ui/BankSummary.stories.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx
similarity index 100%
rename from src/entities/bank-card/ui/BankSummary.stories.tsx
rename to src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx
diff --git a/src/entities/bank-card/ui/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx
similarity index 92%
rename from src/entities/bank-card/ui/BankSummary.view.tsx
rename to src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx
index 6fc9598..6208490 100644
--- a/src/entities/bank-card/ui/BankSummary.view.tsx
+++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx
@@ -1,10 +1,10 @@
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { CSSProperties, FC } from "react";
import { useTranslation } from "react-i18next";
import { formatMoney } from "@/shared/lib";
-import { LoadingSkeleton } from "@/shared/ui";
-import type { BankSummary } from "../api/queries";
-import { BankOperationIcon } from "./BankOperationIcon";
+import type { BankSummary } from "../../api/queries";
+import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
import classes from "./BankSummary.module.css";
type BankSummaryViewProps = {
diff --git a/src/entities/bank-card/ui/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx
similarity index 100%
rename from src/entities/bank-card/ui/CategoryManager.test.tsx
rename to src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx
diff --git a/src/entities/bank-card/ui/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx
similarity index 94%
rename from src/entities/bank-card/ui/CategoryManager.tsx
rename to src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx
index ecb0677..fcd3f9b 100644
--- a/src/entities/bank-card/ui/CategoryManager.tsx
+++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx
@@ -18,12 +18,12 @@ import {
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
-import { useBankMutations } from "../api/mutations";
-import { type ExpenseCategory, useBankCategories } from "../api/queries";
-import { BankFeedback } from "./BankFeedback";
-import classes from "./BankForms.module.css";
-import { BankOperationIcon } from "./BankOperationIcon";
-import { useBankFormFocus } from "./useBankFormFocus";
+import { useBankMutations } from "../../api/mutations";
+import { type ExpenseCategory, useBankCategories } from "../../api/queries";
+import { BankFeedback } from "../BankFeedback";
+import classes from "../BankForms.module.css";
+import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon";
+import { useBankFormFocus } from "../useBankFormFocus";
const MAX_CATEGORY_NAME_LENGTH = 80;
diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts
index 4c21dc5..d69b753 100644
--- a/src/entities/bond/api/queries.ts
+++ b/src/entities/bond/api/queries.ts
@@ -1,8 +1,4 @@
-import {
- apiQueryClient,
- type paths,
- useAuthenticatedQuery,
-} from "@/shared/api";
+import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type BondPeriod = NonNullable<
NonNullable<
@@ -13,7 +9,7 @@ type BondPeriod = NonNullable<
const BOND_STALE_TIME_MS = 30_000;
function useBondSearch(query: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds", {
params: { query: { query } },
}),
@@ -36,11 +32,11 @@ function useBond(identifier: string) {
identifier,
);
- const byUid = useAuthenticatedQuery({
+ const byUid = useResourceQuery({
...bondQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
- const byTicker = useAuthenticatedQuery({
+ const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
}),
@@ -50,7 +46,7 @@ function useBond(identifier: string) {
return isUid ? byUid : byTicker;
}
function useBondMetrics(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", {
params: { path: { uid } },
}),
@@ -59,7 +55,7 @@ function useBondMetrics(uid: string) {
});
}
function useBondQuote(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", {
params: { path: { uid } },
}),
@@ -68,7 +64,7 @@ function useBondQuote(uid: string) {
});
}
function useBondCandles(uid: string, period: BondPeriod) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", {
params: { path: { uid }, query: { period } },
}),
@@ -77,7 +73,8 @@ function useBondCandles(uid: string, period: BondPeriod) {
});
}
function useBondPosition(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", {
params: { path: { uid } },
}),
@@ -86,7 +83,8 @@ function useBondPosition(uid: string) {
});
}
function useBondCoupons(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", {
params: { path: { uid } },
}),
diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts
index 7df4e63..5fc15ca 100644
--- a/src/entities/broker-operation/api/useBrokerageOperations.ts
+++ b/src/entities/broker-operation/api/useBrokerageOperations.ts
@@ -1,16 +1,14 @@
-import {
- apiQueryClient,
- type paths,
- useAuthenticatedQuery,
-} from "@/shared/api";
+import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type OperationsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
-type BrokerageOperation =
- OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
-type BrokerageOperationsQuery = NonNullable<
- OperationsEndpoint["parameters"]["query"]
+type BrokerageOperation = NonNullable<
+ OperationsEndpoint["responses"][200]["content"]["application/json"]["data"]
+>["items"][number];
+type BrokerageOperationsQuery = Omit<
+ NonNullable,
+ "sync"
>;
type OperationCategory = BrokerageOperation["category"];
@@ -56,7 +54,8 @@ function useBrokerageOperations(
accountId: string | undefined,
query: BrokerageOperationsQuery = {},
) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...brokerageOperationsQueryOptions(accountId, query),
enabled: Boolean(accountId),
staleTime: 30_000,
diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx
index 8944e50..8aaab15 100644
--- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx
+++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx
@@ -1,4 +1,5 @@
import { Anchor, Group, Text } from "@mantine/core";
+import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -8,7 +9,6 @@ import {
formatSnapshotTime,
instrumentLogoUrl,
} from "@/shared/lib";
-import { EntryCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display";
diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx
index 047d1f5..795a5c7 100644
--- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx
+++ b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useBrokerageAccounts } from "./useBrokerageAccounts";
@@ -15,14 +16,16 @@ describe("useBrokerageAccounts", () => {
});
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
- HttpResponse.json([
- {
- id: "account-1",
- name: "Основной",
- type: "BROKERAGE",
- openedOn: "2024-08-13",
- },
- ]),
+ HttpResponse.json(
+ resource([
+ {
+ id: "account-1",
+ name: "Основной",
+ type: "BROKERAGE",
+ openedOn: "2024-08-13",
+ },
+ ]),
+ ),
),
);
@@ -52,14 +55,16 @@ describe("useBrokerageAccounts", () => {
http.get("*/api/v1/brokerage-accounts", () => {
calls += 1;
return calls === 1
- ? HttpResponse.json([
- {
- id: "account-1",
- name: "Основной",
- type: "BROKERAGE",
- openedOn: "2024-08-13",
- },
- ])
+ ? HttpResponse.json(
+ resource([
+ {
+ id: "account-1",
+ name: "Основной",
+ type: "BROKERAGE",
+ openedOn: "2024-08-13",
+ },
+ ]),
+ )
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.ts b/src/entities/brokerage-account/api/useBrokerageAccounts.ts
index 83ac0cd..7ae6cec 100644
--- a/src/entities/brokerage-account/api/useBrokerageAccounts.ts
+++ b/src/entities/brokerage-account/api/useBrokerageAccounts.ts
@@ -1,19 +1,17 @@
-import {
- apiQueryClient,
- type paths,
- useAuthenticatedQuery,
-} from "@/shared/api";
+import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
-type BrokerageAccount =
- paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
+type BrokerageAccount = NonNullable<
+ paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>[number];
function useBrokerageAccounts() {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(),
staleTime: 60_000,
});
diff --git a/src/entities/brokerage-account/index.ts b/src/entities/brokerage-account/index.ts
index a6bf808..e2d251c 100644
--- a/src/entities/brokerage-account/index.ts
+++ b/src/entities/brokerage-account/index.ts
@@ -8,4 +8,4 @@ export {
type BrokerageAccountCard,
brokerageAccountCards,
} from "./model/display";
-export { BrokerageAccountList } from "./ui/BrokerageAccountList";
+export { BrokerageAccountList } from "./ui/BrokerageAccountList/BrokerageAccountList";
diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts
index b92e039..21982a3 100644
--- a/src/entities/brokerage-account/model/display.ts
+++ b/src/entities/brokerage-account/model/display.ts
@@ -1,10 +1,18 @@
-import type { BrokerageAccount } from "../api/useBrokerageAccounts";
+import type {
+ BrokerageAccount,
+ useBrokerageAccounts,
+} from "../api/useBrokerageAccounts";
type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
type BrokerageAccountCard = {
+ source?: NonNullable<
+ ReturnType["meta"]
+ >["dependencies"] extends (infer T)[] | undefined
+ ? T
+ : never;
account: BrokerageAccount;
positionCounts: { stocks: number; bonds: number } | null;
summary:
@@ -19,11 +27,19 @@ function accountTypeKey(type: BrokerageAccount["type"]) {
function brokerageAccountCards(
accounts: BrokerageAccount[],
+ dependencies: NonNullable<
+ ReturnType["meta"]
+ >["dependencies"] = [],
): BrokerageAccountCard[] {
return accounts.map((account) => {
+ const source = dependencies.find(
+ (item) =>
+ item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`,
+ );
const summary = account.portfolioSummary;
if (!summary)
return {
+ source,
account,
positionCounts: null,
summary: { state: "unavailable" },
@@ -33,8 +49,9 @@ function brokerageAccountCards(
bonds: summary.bondPositionCount,
};
if (!summary.totalsByCurrency.length)
- return { account, positionCounts, summary: { state: "empty" } };
+ return { source, account, positionCounts, summary: { state: "empty" } };
return {
+ source,
account,
positionCounts,
summary: {
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.module.css b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css
similarity index 100%
rename from src/entities/brokerage-account/ui/BrokerageAccountList.module.css
rename to src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.module.css
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx
similarity index 83%
rename from src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx
rename to src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx
index 0a34306..050a2c9 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList.stories.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router";
-import type { BrokerageAccount } from "../api/useBrokerageAccounts";
-import { brokerageAccountCards } from "../model/display";
+import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
+import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
const syntheticAccount = {
@@ -185,3 +185,44 @@ export const LargeValues: Story = {
]),
},
};
+
+export const IndependentFreshness: Story = {
+ name: "Независимая свежесть и ошибка сводок",
+ args: {
+ accounts: brokerageAccountCards(
+ [
+ syntheticAccount,
+ {
+ ...syntheticAccount,
+ id: "waiting",
+ name: "Новый счёт",
+ portfolioSummary: null,
+ },
+ ],
+ [
+ {
+ resourceKey: "portfolio/storybook-account",
+ dataRevision: "1",
+ stateVersion: "failed",
+ availability: "complete",
+ freshness: "stale",
+ lastSuccessfulAt: "2026-09-24T10:30:00Z",
+ sync: {
+ id: "failed",
+ state: "failed",
+ errorCode: "TBANK_UNAVAILABLE",
+ },
+ },
+ {
+ resourceKey: "portfolio/waiting",
+ dataRevision: "0",
+ stateVersion: "waiting",
+ availability: "missing",
+ freshness: "unknown",
+ lastSuccessfulAt: null,
+ sync: { id: "waiting", state: "running", errorCode: null },
+ },
+ ],
+ ),
+ },
+};
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx
similarity index 71%
rename from src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx
rename to src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx
index 326c9b6..ef5aa02 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList.test.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx
@@ -2,7 +2,8 @@ import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
-import { brokerageAccountCards } from "../model/display";
+import { resource } from "@/test/resource";
+import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
describe("BrokerageAccountList", () => {
@@ -84,3 +85,42 @@ describe("BrokerageAccountList", () => {
).not.toBeInTheDocument();
});
});
+
+it("shows portfolio summary freshness independently for each account", () => {
+ const accounts = brokerageAccountCards(
+ ["fresh", "stale"].map((id) => ({
+ id,
+ name: id,
+ type: "BROKERAGE" as const,
+ openedOn: null,
+ portfolioSummary: {
+ stockPositionCount: 1,
+ bondPositionCount: 0,
+ totalsByCurrency: [{ currency: "RUB", value: "10" }],
+ },
+ })),
+ [
+ resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" })
+ .meta,
+ resource(null, { resourceKey: "portfolio/stale", freshness: "stale" })
+ .meta,
+ ],
+ );
+ render(
+
+
+
+
+ ,
+ );
+ expect(
+ within(screen.getByRole("link", { name: /stale/ })).getByText(
+ "Данные требуют обновления",
+ ),
+ ).toBeVisible();
+ expect(
+ within(screen.getByRole("link", { name: /fresh/ })).queryByText(
+ "Данные требуют обновления",
+ ),
+ ).not.toBeInTheDocument();
+});
diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx
similarity index 78%
rename from src/entities/brokerage-account/ui/BrokerageAccountList.tsx
rename to src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx
index c19b615..6519be2 100644
--- a/src/entities/brokerage-account/ui/BrokerageAccountList.tsx
+++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx
@@ -2,9 +2,9 @@ import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { formatDate, formatMoney } from "@/shared/lib";
-import type { BrokerageAccountCard } from "../model/display";
-import { accountTypeKey } from "../model/display";
+import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib";
+import type { BrokerageAccountCard } from "../../model/display";
+import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css";
type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] };
@@ -52,8 +52,8 @@ const BrokerageAccountList: FC = (props) => {
const { t } = useTranslation();
return (
-
- {accounts.map(({ account, summary, positionCounts }) => (
+
+ {accounts.map(({ account, summary, positionCounts, source }) => (
= (props) => {
{t("accounts.currentValue")}
+ {source?.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(source.lastSuccessfulAt),
+ })}
+
+ ) : null}
+ {source?.sync.state === "running" ||
+ source?.sync.state === "queued" ? (
+
+ {t("accounts.refreshing")}
+
+ ) : null}
+ {source?.sync.state === "failed" ? (
+ {t("accounts.refreshError")}
+ ) : null}
+ {source?.freshness === "stale" ? (
+ {t("investment.stale")}
+ ) : null}
{positionCounts ? (
diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx
index 895b014..3208167 100644
--- a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx
+++ b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx
@@ -3,6 +3,7 @@ import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useBrokerageEvents } from "./useBrokerageEvents";
@@ -31,23 +32,25 @@ describe("useBrokerageEvents", () => {
});
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/events", () =>
- HttpResponse.json({
- accountId: "account-1",
- calculatedAt: "2026-08-14T00:00:00Z",
- events: [
- {
- type: "dividend",
- date: "2026-09-01",
- dateKind: "registry",
- figi: "figi-1",
- instrumentUid: null,
- ticker: "SBER",
- displayName: "Сбер",
- logoName: "sber3.png",
- projectedAmount: { currency: "RUB", value: "12.34" },
- },
- ],
- }),
+ HttpResponse.json(
+ resource({
+ accountId: "account-1",
+ calculatedAt: "2026-08-14T00:00:00Z",
+ events: [
+ {
+ type: "dividend",
+ date: "2026-09-01",
+ dateKind: "registry",
+ figi: "figi-1",
+ instrumentUid: null,
+ ticker: "SBER",
+ displayName: "Сбер",
+ logoName: "sber3.png",
+ projectedAmount: { currency: "RUB", value: "12.34" },
+ },
+ ],
+ }),
+ ),
),
);
diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts
index 7970606..7a2a304 100644
--- a/src/entities/brokerage-event/api/useBrokerageEvents.ts
+++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts
@@ -1,19 +1,17 @@
-import {
- apiQueryClient,
- type paths,
- useAuthenticatedQuery,
-} from "@/shared/api";
+import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
type EventsEndpoint =
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
-type BrokerageEvent =
- EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
+type BrokerageEvent = NonNullable<
+ EventsEndpoint["responses"][200]["content"]["application/json"]["data"]
+>["events"][number];
function useBrokerageEvents(
accountId: string | undefined,
- query: EventsEndpoint["parameters"]["query"] = {},
+ query: Omit, "sync"> = {},
) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/events",
diff --git a/src/entities/brokerage-event/index.ts b/src/entities/brokerage-event/index.ts
index af8c9e0..f3b2e91 100644
--- a/src/entities/brokerage-event/index.ts
+++ b/src/entities/brokerage-event/index.ts
@@ -6,4 +6,4 @@ export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export {
BrokerageEventsPreview,
type BrokerageEventsPreviewState,
-} from "./ui/BrokerageEventsPreview";
+} from "./ui/BrokerageEventsPreview/BrokerageEventsPreview";
diff --git a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx
index feb11bb..18f61f7 100644
--- a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx
+++ b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx
@@ -1,7 +1,7 @@
+import { EntryCard } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
-import { EntryCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
type BrokerageEventCardProps = { event: BrokerageEvent };
diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview.stories.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx
similarity index 98%
rename from src/entities/brokerage-event/ui/BrokerageEventsPreview.stories.tsx
rename to src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx
index 16a55dc..0f8f4dc 100644
--- a/src/entities/brokerage-event/ui/BrokerageEventsPreview.stories.tsx
+++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router";
-import type { BrokerageEvent } from "../api/useBrokerageEvents";
+import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { BrokerageEventsPreview } from "./BrokerageEventsPreview";
const syntheticEvent = {
diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
similarity index 58%
rename from src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx
rename to src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
index 36fff05..b6623f3 100644
--- a/src/entities/brokerage-event/ui/BrokerageEventsPreview.tsx
+++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx
@@ -1,10 +1,11 @@
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { LoadingSkeleton } from "@/shared/ui";
-import type { BrokerageEvent } from "../api/useBrokerageEvents";
-import { BrokerageEventCard } from "./BrokerageEventCard";
+import { formatSnapshotTime } from "@/shared/lib";
+import type { BrokerageEvent } from "../../api/useBrokerageEvents";
+import { BrokerageEventCard } from "../BrokerageEventCard";
type BrokerageEventsPreviewProps = {
events: BrokerageEventsPreviewState;
@@ -12,7 +13,14 @@ type BrokerageEventsPreviewProps = {
type BrokerageEventsPreviewState =
| { state: "loading" | "error" | "empty"; to: string }
- | { state: "loaded"; to: string; items: BrokerageEvent[] };
+ | {
+ state: "loaded" | "partial";
+ to: string;
+ lastSuccessfulAt?: string | null;
+ stale?: boolean;
+ refreshError?: boolean;
+ items: BrokerageEvent[];
+ };
const BrokerageEventsPreview: FC = (props) => {
const { events } = props;
@@ -20,6 +28,21 @@ const BrokerageEventsPreview: FC = (props) => {
return (
+ {events.state === "loaded" ? (
+ <>
+ {events.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(events.lastSuccessfulAt),
+ })}
+
+ ) : null}
+ {events.stale ? {t("investment.stale")} : null}
+ {events.refreshError ? (
+ {t("accounts.refreshError")}
+ ) : null}
+ >
+ ) : null}
{events.state === "loading" ? (
{[0, 1, 2].map((index) => (
@@ -33,7 +56,10 @@ const BrokerageEventsPreview: FC = (props) => {
{events.state === "empty" ? (
{t("events.recentEmpty")}
) : null}
- {events.state === "loaded"
+ {events.state === "partial" ? (
+ {t("investment.partial")}
+ ) : null}
+ {events.state === "loaded" || events.state === "partial"
? events.items.map((event) => (
;
+type IncomeAnalytics = NonNullable<
+ paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
function useIncomeSummary(accountId: string | undefined) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/income-summary",
@@ -25,7 +24,8 @@ function useIncomeAnalytics(
accountId: string | undefined,
period: { from: string; to: string },
) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/income-analytics",
diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx
new file mode 100644
index 0000000..6bf9d4f
--- /dev/null
+++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx
@@ -0,0 +1,144 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { describe, expect, it, vi } from "vitest";
+import { apiClient, configureAuthClient } from "@/shared/api";
+import { createQueryWrapper } from "@/test/query";
+import { resource } from "@/test/resource";
+import { server } from "@/test/server";
+import {
+ type Portfolio,
+ portfolioQueryOptions,
+ usePortfolio,
+} from "./usePortfolio";
+
+const snapshot: Portfolio = {
+ positions: [],
+ totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }],
+ cashBalances: [],
+ structure: { currencies: [], unvaluedCategories: [] },
+ lastSuccessfulAt: "2026-08-14T00:00:00Z",
+ isStale: false,
+};
+function auth() {
+ configureAuthClient({
+ accessToken: () => "synthetic",
+ refresh: async () => true,
+ clear: vi.fn(),
+ });
+}
+
+describe("persistent resource query lifecycle", () => {
+ it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => {
+ auth();
+ const modes: (string | null)[] = [];
+ let observations = 0;
+ server.use(
+ http.get(
+ "*/api/v1/brokerage-accounts/:accountId/portfolio",
+ ({ request }) => {
+ const mode = new URL(request.url).searchParams.get("sync");
+ modes.push(mode);
+ if (mode) observations += 1;
+ return HttpResponse.json(
+ resource(snapshot, {
+ stateVersion: observations > 1 ? "new" : "old",
+ }),
+ );
+ },
+ ),
+ );
+ const { wrapper, queryClient } = createQueryWrapper();
+ const { result } = renderHook(() => usePortfolio("a"), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ await act(() => result.current.observe("new"));
+ await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new"));
+ expect(modes).toEqual([null, "observe", "observe"]);
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(1);
+ expect(
+ queryClient.getQueryData(portfolioQueryOptions("a").queryKey),
+ ).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot });
+ });
+ it("does not convert missing or failed source data to empty financial success", async () => {
+ auth();
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
+ HttpResponse.json(
+ resource(null, {
+ sync: {
+ id: "work",
+ state: "failed",
+ errorCode: "TBANK_UNAVAILABLE",
+ },
+ }),
+ ),
+ ),
+ );
+ const { result } = renderHook(() => usePortfolio("a"), {
+ wrapper: createQueryWrapper().wrapper,
+ });
+ await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
+ expect(result.current.data).toBeUndefined();
+ expect(result.current.isSuccess).toBe(false);
+ expect(result.current.meta?.availability).toBe("missing");
+ });
+ it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
+ auth();
+ let release!: () => void;
+ const blocked = new Promise((resolve) => {
+ release = resolve;
+ });
+ server.use(
+ http.get(
+ "*/api/v1/brokerage-accounts/:accountId/portfolio",
+ async ({ request }) => {
+ if (!new URL(request.url).searchParams.has("sync")) await blocked;
+ return HttpResponse.json(resource(snapshot));
+ },
+ ),
+ );
+ const old = apiClient.GET(
+ "/api/v1/brokerage-accounts/{accountId}/portfolio",
+ { params: { path: { accountId: "a" } } },
+ );
+ const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" });
+ await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", {
+ params: { path: { accountId: "a" }, query: { sync: "observe" } },
+ });
+ release();
+ await rejected;
+ });
+ it("clears private snapshots on confirmed access loss and fences another pending success", async () => {
+ auth();
+ let denied = false;
+ let release!: () => void;
+ const blocked = new Promise((resolve) => {
+ release = resolve;
+ });
+ server.use(
+ http.get(
+ "*/api/v1/brokerage-accounts/:accountId/portfolio",
+ async ({ params }) => {
+ if (params.accountId === "b") await blocked;
+ if (params.accountId === "a" && denied)
+ return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
+ return HttpResponse.json(resource(snapshot));
+ },
+ ),
+ );
+ const { wrapper } = createQueryWrapper();
+ const { result } = renderHook(
+ () => ({ a: usePortfolio("a"), b: usePortfolio("b") }),
+ { wrapper },
+ );
+ await waitFor(() => expect(result.current.a.data).toEqual(snapshot));
+ denied = true;
+ await act(() => result.current.a.refetch());
+ await waitFor(() => expect(result.current.a.data).toBeUndefined());
+ await act(async () => {
+ release();
+ await blocked;
+ });
+ expect(result.current.b.data).toBeUndefined();
+ expect(result.current.a.isError).toBe(true);
+ });
+});
diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx
index c91233d..efb6e4c 100644
--- a/src/entities/portfolio/api/usePortfolio.test.tsx
+++ b/src/entities/portfolio/api/usePortfolio.test.tsx
@@ -1,9 +1,9 @@
import { renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
-
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
@@ -33,14 +33,16 @@ describe("usePortfolio", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
calls += 1;
return calls === 1
- ? HttpResponse.json({
- lastSuccessfulAt: "2026-08-14T00:00:00Z",
- isStale: false,
- totalsByCurrency: [],
- positions: [],
- structure: { currencies: [], unvaluedCategories: [] },
- cashBalances: [],
- })
+ ? HttpResponse.json(
+ resource({
+ lastSuccessfulAt: "2026-08-14T00:00:00Z",
+ isStale: false,
+ totalsByCurrency: [],
+ positions: [],
+ structure: { currencies: [], unvaluedCategories: [] },
+ cashBalances: [],
+ }),
+ )
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
diff --git a/src/entities/portfolio/api/usePortfolio.ts b/src/entities/portfolio/api/usePortfolio.ts
index 9326702..0514463 100644
--- a/src/entities/portfolio/api/usePortfolio.ts
+++ b/src/entities/portfolio/api/usePortfolio.ts
@@ -1,15 +1,12 @@
-import {
- apiQueryClient,
- type paths,
- useAuthenticatedQuery,
-} from "@/shared/api";
+import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api";
const portfolioQueryPolicy = {
staleTime: 30_000,
} as const;
-type Portfolio =
- paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
+type Portfolio = NonNullable<
+ paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
function portfolioQueryOptions(accountId: string | undefined) {
return apiQueryClient.queryOptions(
@@ -20,7 +17,8 @@ function portfolioQueryOptions(accountId: string | undefined) {
}
function usePortfolio(accountId: string | undefined) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...portfolioQueryOptions(accountId),
enabled: Boolean(accountId),
...portfolioQueryPolicy,
diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts
index 29967c0..26081ab 100644
--- a/src/entities/stock/api/queries.ts
+++ b/src/entities/stock/api/queries.ts
@@ -1,8 +1,9 @@
import {
apiQueryClient,
type paths,
+ unwrapResourceResult,
useAuthenticatedQueries,
- useAuthenticatedQuery,
+ useResourceQuery,
} from "@/shared/api";
type StockPeriod = NonNullable<
@@ -14,7 +15,7 @@ type StockPeriod = NonNullable<
const STOCK_STALE_TIME_MS = 30_000;
function useStockSearch(query: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
@@ -35,7 +36,10 @@ function useStocks(uids: string[]) {
const uniqueUids = [...new Set(uids)];
const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions));
return new Map(
- queries.map((query, index) => [uniqueUids[index], query.data]),
+ queries.map((query, index) => [
+ uniqueUids[index],
+ unwrapResourceResult(query).data,
+ ]),
);
}
@@ -45,11 +49,11 @@ function useStock(identifier: string) {
identifier,
);
- const byUid = useAuthenticatedQuery({
+ const byUid = useResourceQuery({
...stockQueryOptions(identifier),
enabled: Boolean(identifier) && isUid,
});
- const byTicker = useAuthenticatedQuery({
+ const byTicker = useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", {
params: { path: { ticker: identifier } },
}),
@@ -59,7 +63,7 @@ function useStock(identifier: string) {
return isUid ? byUid : byTicker;
}
function useStockQuote(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", {
params: { path: { uid } },
}),
@@ -68,7 +72,7 @@ function useStockQuote(uid: string) {
});
}
function useStockCandles(uid: string, period: StockPeriod) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", {
params: { path: { uid }, query: { period } },
}),
@@ -77,7 +81,8 @@ function useStockCandles(uid: string, period: StockPeriod) {
});
}
function useStockPosition(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", {
params: { path: { uid } },
}),
@@ -86,7 +91,7 @@ function useStockPosition(uid: string) {
});
}
function useStockCompany(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", {
params: { path: { uid } },
}),
@@ -95,7 +100,8 @@ function useStockCompany(uid: string) {
});
}
function useStockDividends(uid: string) {
- return useAuthenticatedQuery({
+ return useResourceQuery({
+ meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/dividends", {
params: { path: { uid } },
}),
diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts
index 0c25e28..9322901 100644
--- a/src/features/auth/index.ts
+++ b/src/features/auth/index.ts
@@ -1,4 +1,4 @@
export { AuthProvider, useAuth } from "./model/auth";
-export { AuthPageLayout } from "./ui/AuthPageLayout";
+export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout";
export { LoginForm } from "./ui/LoginForm";
export { RegisterForm } from "./ui/RegisterForm";
diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx
index ecc997d..972d790 100644
--- a/src/features/auth/model/auth.test.tsx
+++ b/src/features/auth/model/auth.test.tsx
@@ -3,8 +3,9 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { describe, expect, it } from "vitest";
-import { apiClient, apiQueryClient } from "@/shared/api";
+import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AuthProvider, useAuth } from "./auth";
@@ -24,7 +25,7 @@ describe("AuthProvider", () => {
),
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization = request.headers.get("Authorization");
- return HttpResponse.json([]);
+ return HttpResponse.json(resource([]));
}),
);
const queryClient = createTestQueryClient();
@@ -167,4 +168,164 @@ describe("AuthProvider", () => {
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
+ it.each([
+ [false, false],
+ [true, false],
+ [false, true],
+ [true, true],
+ ])(
+ "serializes late refresh (failure=%s, login=%s)",
+ async (failure, loginAfterLogout) => {
+ let release!: () => void;
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ let calls = 0;
+ const order: string[] = [];
+ server.use(
+ http.post("*/api/v1/auth/refresh", async () => {
+ if (++calls > 1) {
+ order.push("refresh-start");
+ await pending;
+ order.push("refresh-cookie");
+ }
+ return failure && calls > 1
+ ? HttpResponse.error()
+ : HttpResponse.json({
+ accessToken: "a",
+ user: { id: "a", email: "a@example.com" },
+ });
+ }),
+ http.post("*/api/v1/auth/logout", () => {
+ order.push("logout-cookie");
+ return new HttpResponse(null, { status: 204 });
+ }),
+ http.post("*/api/v1/auth/login", () => {
+ order.push("login-cookie");
+ return HttpResponse.json({
+ accessToken: "b",
+ user: { id: "b", email: "b@example.com" },
+ });
+ }),
+ http.get("*/api/v1/brokerage-accounts", () =>
+ HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
+ ),
+ );
+ const queryClient = createTestQueryClient();
+ const wrapper: FC = ({ children }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useAuth(), { wrapper });
+ await waitFor(() => expect(result.current.ready).toBe(true));
+ const oldRequest = apiClient
+ .GET("/api/v1/brokerage-accounts")
+ .catch(() => null);
+ await waitFor(() => expect(order).toContain("refresh-start"));
+ let logout!: Promise;
+ act(() => {
+ logout = result.current.logout();
+ });
+ await waitFor(() => expect(result.current.user).toBeNull());
+ let login!: Promise;
+ act(() => {
+ login = loginAfterLogout
+ ? result.current.login("b@example.com", "password")
+ : Promise.resolve();
+ });
+ await act(async () => {
+ release();
+ await Promise.all([oldRequest, logout, login]);
+ });
+ expect(result.current.user?.id ?? null).toBe(
+ loginAfterLogout ? "b" : null,
+ );
+ expect(order).toEqual([
+ "refresh-start",
+ "refresh-cookie",
+ "logout-cookie",
+ ...(loginAfterLogout ? ["login-cookie"] : []),
+ ]);
+ },
+ );
+
+ it("preserves same-user cache and removes inactive and pending data on refresh identity change", async () => {
+ let identity = "a";
+ server.use(
+ http.post("*/api/v1/auth/refresh", () =>
+ HttpResponse.json({
+ accessToken: identity,
+ user: { id: identity, email: `${identity}@example.com` },
+ }),
+ ),
+ );
+ const queryClient = createTestQueryClient();
+ const wrapper: FC = ({ children }) => (
+
+ {children}
+
+ );
+ const { result } = renderHook(() => useAuth(), { wrapper });
+ await waitFor(() => expect(result.current.ready).toBe(true));
+ queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
+ await act(() => result.current.refresh());
+ expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
+ let release!: (value: string) => void;
+ const pending = queryClient
+ .fetchQuery({
+ queryKey: ["pending-finance"],
+ queryFn: () =>
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ })
+ .catch(() => null);
+ identity = "b";
+ await act(() => result.current.refresh());
+ release("a-data");
+ await pending;
+ expect(result.current.user?.id).toBe("b");
+ expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
+ expect(queryClient.getQueryData(["pending-finance"])).toBeUndefined();
+ });
+ it("replaces an observed financial query when refresh changes identity", async () => {
+ let identity = "a";
+ server.use(
+ http.post("*/api/v1/auth/refresh", () =>
+ HttpResponse.json({
+ accessToken: identity,
+ user: { id: identity, email: `${identity}@example.com` },
+ }),
+ ),
+ http.get("*/api/v1/bank-cards", () =>
+ HttpResponse.json([{ id: `${identity}-card` }]),
+ ),
+ );
+ const queryClient = createTestQueryClient();
+ const wrapper: FC = (props) => (
+
+ {props.children}
+
+ );
+ const { result } = renderHook(
+ () => ({
+ auth: useAuth(),
+ cards: useAuthenticatedQuery(
+ apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
+ params: { query: { includeArchived: "false" } },
+ }),
+ ),
+ }),
+ { wrapper },
+ );
+ await waitFor(() =>
+ expect(result.current.cards.data?.[0].id).toBe("a-card"),
+ );
+ identity = "b";
+ await act(() => result.current.auth.refresh());
+ await waitFor(() =>
+ expect(result.current.cards.data?.[0].id).toBe("b-card"),
+ );
+ });
});
diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx
index 6bd700c..3830b6d 100644
--- a/src/features/auth/model/auth.tsx
+++ b/src/features/auth/model/auth.tsx
@@ -47,49 +47,70 @@ const AuthProvider: FC = (props) => {
const accessTokenRef = useRef(null);
const [ready, setReady] = useState(false);
+ const generation = useRef(0);
+ const userRef = useRef(null);
+ const authQueue = useRef>(Promise.resolve());
const refreshPromise = useRef | null>(null);
- const applyAuthentication = useCallback((authentication: Authentication) => {
- setUser(authentication.user);
- setAccessToken(authentication.accessToken);
- accessTokenRef.current = authentication.accessToken;
+ // Cookie-writing requests share ordering, including across tabs with Web Locks.
+ const serialize = useCallback((run: () => Promise): Promise => {
+ const next = authQueue.current.then(async () =>
+ "locks" in navigator
+ ? navigator.locks.request("wealth-ledger-refresh", run)
+ : run(),
+ );
+ authQueue.current = next.catch(() => undefined);
+ return next;
}, []);
+ const applyAuthentication = useCallback(
+ (authentication: Authentication) => {
+ const changedIdentity =
+ userRef.current && userRef.current.id !== authentication.user.id;
+ accessTokenRef.current = authentication.accessToken;
+ if (changedIdentity) {
+ generation.current += 1;
+ queryClient.removeQueries({ type: "inactive" });
+ queryClient.getMutationCache().clear();
+ void queryClient.resetQueries();
+ }
+ userRef.current = authentication.user;
+ setUser(authentication.user);
+ setAccessToken(authentication.accessToken);
+ },
+ [queryClient],
+ );
const clearSession = useCallback(() => {
+ generation.current += 1;
+ userRef.current = null;
setUser(null);
setAccessToken(null);
accessTokenRef.current = null;
queryClient.clear();
}, [queryClient]);
- const refresh = useCallback(async (): Promise => {
+ const refresh = useCallback((): Promise => {
if (refreshPromise.current) return refreshPromise.current;
- refreshPromise.current = (async () => {
- const run = async () => {
- try {
- const { data } = await apiClient.POST("/api/v1/auth/refresh");
- if (!data) {
- clearSession();
- return false;
- }
- applyAuthentication(data as Authentication);
- return true;
- } catch {
+ const started = generation.current;
+ const pending = serialize(async () => {
+ if (started !== generation.current) return false;
+ try {
+ const { data } = await apiClient.POST("/api/v1/auth/refresh");
+ if (started !== generation.current) return false;
+ if (!data) {
clearSession();
return false;
}
- };
- try {
- return "locks" in navigator
- ? navigator.locks.request("wealth-ledger-refresh", run)
- : run();
+ applyAuthentication(data);
+ return true;
} catch {
- clearSession();
+ if (started === generation.current) clearSession();
return false;
}
- })().finally(() => {
- refreshPromise.current = null;
+ }).finally(() => {
+ if (refreshPromise.current === pending) refreshPromise.current = null;
});
- return refreshPromise.current;
- }, [applyAuthentication, clearSession]);
+ refreshPromise.current = pending;
+ return pending;
+ }, [applyAuthentication, clearSession, serialize]);
useEffect(() => {
void refresh().finally(() => setReady(true));
@@ -100,26 +121,30 @@ const AuthProvider: FC = (props) => {
email: string,
password: string,
) {
- const authentication = await request(path, email, password);
- applyAuthentication(authentication);
+ clearSession();
+ const started = generation.current;
+ await serialize(async () => {
+ const authentication = await request(path, email, password);
+ if (started === generation.current) applyAuthentication(authentication);
+ });
}
async function logout() {
- try {
+ clearSession();
+ await serialize(async () => {
await apiClient.POST("/api/v1/auth/logout");
- } finally {
- clearSession();
- }
+ });
}
useEffect(() => {
configureAuthClient({
accessToken: () => accessTokenRef.current,
+ generation: () => generation.current,
refresh,
clear: clearSession,
});
- return () => configureAuthClient(null);
});
+ useEffect(() => () => configureAuthClient(null), []);
return (
{
const { login } = useAuth();
diff --git a/src/features/auth/ui/RegisterForm.tsx b/src/features/auth/ui/RegisterForm.tsx
index 913d9c9..f666da9 100644
--- a/src/features/auth/ui/RegisterForm.tsx
+++ b/src/features/auth/ui/RegisterForm.tsx
@@ -1,7 +1,7 @@
import type { FC } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth";
-import { AuthForm } from "./AuthForm";
+import { AuthForm } from "./AuthForm/AuthForm";
const RegisterForm: FC = () => {
const { register } = useAuth();
diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts
new file mode 100644
index 0000000..e241987
--- /dev/null
+++ b/src/features/instrument-details/model/section.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it, vi } from "vitest";
+import { resource } from "@/test/resource";
+import { section } from "./section";
+
+describe("instrument source and transport presentation", () => {
+ it.each(["missing", "partial", "complete"] as const)(
+ "keeps %s separate from successful transport",
+ (availability) => {
+ const data = availability === "missing" ? undefined : { rows: [] };
+ const state = section({
+ data,
+ meta: resource(data, { availability }).meta,
+ isError: false,
+ isFetching: false,
+ refetch: vi.fn(),
+ });
+ expect(state.loading).toBe(availability === "missing");
+ expect(state.partial).toBe(availability === "partial");
+ 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();
+ const meta = resource([], {
+ sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
+ }).meta;
+ const state = section(
+ { data: [], meta, isError: false, isFetching: false, refetch },
+ retry,
+ );
+ expect(state).toMatchObject({
+ hasData: true,
+ loading: false,
+ error: true,
+ refreshing: false,
+ lastSuccessfulAt: meta.lastSuccessfulAt,
+ });
+ state.retry();
+ expect(retry).toHaveBeenCalledOnce();
+ expect(refetch).not.toHaveBeenCalled();
+ });
+ it("shows source failure without a false loading or empty result", () => {
+ const state = section({
+ data: undefined,
+ meta: resource(null, {
+ sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" },
+ }).meta,
+ isError: false,
+ isFetching: false,
+ refetch: vi.fn(),
+ });
+ expect(state).toMatchObject({
+ hasData: false,
+ loading: false,
+ error: true,
+ });
+ });
+});
diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts
index 972d7e6..109acb9 100644
--- a/src/features/instrument-details/model/section.ts
+++ b/src/features/instrument-details/model/section.ts
@@ -1,23 +1,44 @@
+import type { paths } from "@/shared/api";
+
+type Meta =
+ paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type SectionState = {
+ stale?: boolean;
+ sourceError?: boolean;
+ partial?: boolean;
+ lastSuccessfulAt?: string | null;
hasData: boolean;
loading: boolean;
error: boolean;
refreshing: boolean;
retry: () => void;
};
-function section(query: {
- data: unknown;
- isError: boolean;
- isFetching: boolean;
- refetch: () => unknown;
-}): SectionState {
+function section(
+ query: {
+ data: unknown;
+ meta?: Meta;
+ isError: boolean;
+ isFetching: boolean;
+ refetch: () => unknown;
+ },
+ retry?: () => unknown,
+): SectionState {
+ const error = query.isError || query.meta?.sync.state === "failed";
return {
+ stale: query.meta?.freshness === "stale",
+ sourceError: query.meta?.sync.state === "failed",
+ partial: query.meta?.availability === "partial",
+ lastSuccessfulAt: query.meta?.lastSuccessfulAt,
hasData: Boolean(query.data),
- loading: !query.data && !query.isError,
- error: query.isError,
- refreshing: Boolean(query.data) && query.isFetching,
+ loading: !query.data && !error,
+ error,
+ refreshing:
+ Boolean(query.data) &&
+ (query.isFetching ||
+ query.meta?.sync.state === "running" ||
+ query.meta?.sync.state === "queued"),
retry: () => {
- void query.refetch();
+ void (retry ?? query.refetch)();
},
};
}
diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts
index ccfa9b1..f3dbd2d 100644
--- a/src/features/instrument-details/model/types.ts
+++ b/src/features/instrument-details/model/types.ts
@@ -2,10 +2,12 @@ import type { BrokerageOperation } from "@/entities/broker-operation";
import type { paths } from "@/shared/api";
import type { SectionState } from "./section";
-type Position =
- paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"];
-type Candles =
- paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"];
+type Position = NonNullable<
+ paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
+type Candles = NonNullable<
+ paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]
+>;
type ChartRow = Candles["candles"][number] & { date: string; price: number };
type InstrumentPositionVm = {
position?: Position;
diff --git a/src/features/instrument-details/ui/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart.view.tsx
index 0e1a7e0..100050e 100644
--- a/src/features/instrument-details/ui/InstrumentChart.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentChart.view.tsx
@@ -177,7 +177,13 @@ const InstrumentChart: FC = (props) => {
>
) : (
- {t("stock.noCandles")}
+
+ {t(
+ vm.candlesState.partial
+ ? "investment.partial"
+ : "stock.noCandles",
+ )}
+
)
) : null}
diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx
index 5d7f873..53195c0 100644
--- a/src/features/instrument-details/ui/InstrumentEvents.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx
@@ -60,7 +60,11 @@ const InstrumentEvents: FC = (props) => {
) : (
- {t("stock.noOperations")}
+ {t(
+ vm.operationsState.partial
+ ? "investment.partial"
+ : "stock.noOperations",
+ )}
)}
diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx
index f106845..ff8fea3 100644
--- a/src/features/instrument-details/ui/InstrumentSection.view.tsx
+++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx
@@ -8,9 +8,10 @@ import {
Text,
Title,
} from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC, PropsWithChildren, ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
+import { formatSnapshotTime } from "@/shared/lib";
import type { SectionState } from "../model/section";
import classes from "./InstrumentDetails.module.css";
@@ -38,20 +39,40 @@ const InstrumentSection: FC = (props) => {
{props.controls}
+ {props.state.stale ? (
+ {t("investment.stale")}
+ ) : null}
+ {props.state.partial ? (
+ {t("investment.partial")}
+ ) : null}
+ {props.state.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(props.state.lastSuccessfulAt),
+ })}
+
+ ) : null}
{props.state.refreshing ? (
{t("accounts.refreshing")}
) : null}
{props.state.error ? (
-
+
- {props.state.hasData
- ? t("accounts.refreshError")
- : t("stock.sectionError")}
+ {props.state.sourceError
+ ? t("investment.syncFailed")
+ : props.state.hasData
+ ? t("accounts.refreshError")
+ : t("stock.sectionError")}
-
diff --git a/src/features/tbank-connection/index.ts b/src/features/tbank-connection/index.ts
index c128eb1..5651582 100644
--- a/src/features/tbank-connection/index.ts
+++ b/src/features/tbank-connection/index.ts
@@ -1 +1,7 @@
-export { TbankConnectionSection } from "./ui/TbankConnectionSection";
+export { useScreenSync } from "./model/screen-sync";
+export {
+ type InvestmentScreen,
+ useTbankSync,
+ useTbankSyncStatus,
+} from "./model/sync";
+export { TbankConnectionSection } from "./ui/TbankConnectionSection/TbankConnectionSection";
diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts
index 440864b..14670ba 100644
--- a/src/features/tbank-connection/model/connection-boundary.ts
+++ b/src/features/tbank-connection/model/connection-boundary.ts
@@ -1,5 +1,9 @@
import type { QueryClient } from "@tanstack/react-query";
-import { apiQueryClient, type paths } from "@/shared/api";
+import {
+ apiQueryClient,
+ invalidateApiRequests,
+ type paths,
+} from "@/shared/api";
type TbankConnectionStatus =
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
@@ -15,6 +19,13 @@ function commitTbankConnectionBoundary(
queryClient: QueryClient,
status: TbankConnectionStatus,
) {
+ invalidateApiRequests(
+ (path) =>
+ path.startsWith("/api/v1/brokerage-accounts") ||
+ path.startsWith("/api/v1/stocks") ||
+ path.startsWith("/api/v1/bonds") ||
+ path.startsWith("/api/v1/tbank-sync"),
+ );
queryClient.setQueryData(tbankConnectionQueryKey, status);
void queryClient.resetQueries({
predicate: ({ queryKey }) => {
@@ -22,7 +33,8 @@ function commitTbankConnectionBoundary(
return (
method === "get" &&
typeof path === "string" &&
- (path === "/api/v1/bonds" ||
+ (path.startsWith("/api/v1/tbank-sync") ||
+ path === "/api/v1/bonds" ||
path.startsWith("/api/v1/bonds/") ||
path === "/api/v1/stocks" ||
path.startsWith("/api/v1/stocks/") ||
diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx
new file mode 100644
index 0000000..434ab27
--- /dev/null
+++ b/src/features/tbank-connection/model/screen-sync.test.tsx
@@ -0,0 +1,268 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { describe, expect, it, vi } from "vitest";
+import { useBrokerageAccounts } from "@/entities/brokerage-account";
+import { configureAuthClient } from "@/shared/api";
+import { createQueryWrapper } from "@/test/query";
+import { resource } from "@/test/resource";
+import { server } from "@/test/server";
+import { useScreenSync } from "../index";
+
+function auth() {
+ configureAuthClient({
+ accessToken: () => "synthetic",
+ refresh: async () => true,
+ clear: vi.fn(),
+ });
+}
+describe("page-owned screen synchronization", () => {
+ it("posts only its scope once and observes completion without importing again", async () => {
+ auth();
+ const modes: (string | null)[] = [];
+ const bodies: unknown[] = [];
+ let completed = false;
+ let polls = 0;
+ const meta = () =>
+ resource([], { stateVersion: completed ? "2" : "1" }).meta;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", ({ request }) => {
+ modes.push(new URL(request.url).searchParams.get("sync"));
+ return HttpResponse.json(resource([], meta()));
+ }),
+ http.post("*/api/v1/tbank-sync", async ({ request }) => {
+ bodies.push(await request.json());
+ completed = true;
+ return HttpResponse.json(
+ {
+ id: "screen",
+ scope: { screen: "accounts" },
+ now: "2026-09-25T00:00:00Z",
+ },
+ { status: 202 },
+ );
+ }),
+ http.get("*/api/v1/tbank-sync/:id", () => {
+ polls++;
+ return HttpResponse.json({
+ id: "screen",
+ scope: { screen: "accounts" },
+ now: "2026-09-25T00:00:00Z",
+ state: "succeeded",
+ errorCode: null,
+ progress: { total: 1, completed: 1, discovering: false },
+ resources: [meta()],
+ });
+ }),
+ );
+ const { result, unmount } = renderHook(
+ () => {
+ const accounts = useBrokerageAccounts();
+ return {
+ accounts,
+ sync: useScreenSync({ screen: "accounts" }, [accounts]),
+ };
+ },
+ { wrapper: createQueryWrapper().wrapper },
+ );
+ await waitFor(() => expect(result.current.accounts.data).toEqual([]));
+ await act(async () => {
+ await Promise.all([
+ result.current.sync.refresh(),
+ result.current.sync.refresh(),
+ ]);
+ });
+ await waitFor(() =>
+ expect(result.current.accounts.meta?.stateVersion).toBe("2"),
+ );
+ expect(bodies).toEqual([{ screen: "accounts" }]);
+ expect(modes).toEqual([null, "observe"]);
+ vi.useFakeTimers();
+ try {
+ await act(() => vi.advanceTimersByTimeAsync(120000));
+ expect(polls).toBe(1);
+ expect(modes).toEqual([null, "observe"]);
+ unmount();
+ await vi.advanceTimersByTimeAsync(120000);
+ expect(polls).toBe(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+ it.each(["leave", "logout"])(
+ "publishes completed empty coverage while history runs, then stops on %s",
+ async (boundary) => {
+ vi.useFakeTimers({ toFake: ["Date"] });
+ try {
+ auth();
+ let completed = false;
+ let polls = 0;
+ const modes: (string | null)[] = [];
+ const snapshot = () =>
+ resource(completed ? [] : null, {
+ stateVersion: completed ? "covered" : "initial",
+ sync: { id: "history", state: "running", errorCode: null },
+ });
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", ({ request }) => {
+ modes.push(new URL(request.url).searchParams.get("sync"));
+ return HttpResponse.json(snapshot());
+ }),
+ http.get("*/api/v1/tbank-sync/:id", () => {
+ polls++;
+ return HttpResponse.json({
+ id: "history",
+ scope: null,
+ now: "2026-09-25T00:00:00Z",
+ state: "running",
+ errorCode: null,
+ progress: {
+ total: 2,
+ completed: completed ? 1 : 0,
+ discovering: true,
+ },
+ resources: [snapshot().meta],
+ });
+ }),
+ );
+ const { result, unmount } = renderHook(
+ () => {
+ const accounts = useBrokerageAccounts();
+ return {
+ accounts,
+ sync: useScreenSync({ screen: "accounts" }, [accounts]),
+ };
+ },
+ { wrapper: createQueryWrapper().wrapper },
+ );
+ await waitFor(() =>
+ expect(result.current.accounts.meta?.sync.id).toBe("history"),
+ );
+ await waitFor(() => expect(polls).toBeGreaterThan(0));
+ expect(result.current.sync.refreshing).toBe(true);
+ expect(result.current.accounts.data).toBeUndefined();
+ completed = true;
+ vi.setSystemTime(Date.now() + 61000);
+ 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"]);
+ if (boundary === "leave") unmount();
+ else await act(() => configureAuthClient(null));
+ const stopped = polls;
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1100));
+ });
+ expect(polls).toBe(stopped);
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+ );
+
+ it("clears private snapshots when status confirms access loss", async () => {
+ auth();
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () =>
+ HttpResponse.json(
+ resource([], {
+ sync: { id: "revoked", state: "running", errorCode: null },
+ }),
+ ),
+ ),
+ http.get("*/api/v1/tbank-sync/:id", () =>
+ HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
+ ),
+ );
+ const { result } = renderHook(
+ () => {
+ const accounts = useBrokerageAccounts();
+ return {
+ accounts,
+ sync: useScreenSync({ screen: "accounts" }, [accounts]),
+ };
+ },
+ { wrapper: createQueryWrapper().wrapper },
+ );
+ await waitFor(() => expect(result.current.accounts.isError).toBe(true));
+ expect(result.current.accounts.data).toBeUndefined();
+ });
+ it("observes a projection when only its dependency reports new coverage", async () => {
+ auth();
+ let observed = false;
+ const modes: (string | null)[] = [];
+ const dependency = resource([], {
+ resourceKey: "operations",
+ stateVersion: observed ? "covered" : "initial",
+ sync: {
+ id: "dependency",
+ state: observed ? "idle" : "running",
+ errorCode: null,
+ },
+ }).meta;
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", ({ request }) => {
+ const mode = new URL(request.url).searchParams.get("sync");
+ modes.push(mode);
+ if (mode) observed = true;
+ return HttpResponse.json(
+ resource(observed ? [] : null, {
+ resourceKey: "projection",
+ dependencies: [
+ { ...dependency, stateVersion: observed ? "covered" : "initial" },
+ ],
+ }),
+ );
+ }),
+ http.get("*/api/v1/tbank-sync/:id", () =>
+ HttpResponse.json({
+ id: "dependency",
+ scope: null,
+ now: "2026-09-25T00:00:00Z",
+ state: "succeeded",
+ errorCode: null,
+ progress: { total: 1, completed: 1, discovering: false },
+ resources: [{ ...dependency, stateVersion: "covered" }],
+ }),
+ ),
+ );
+ const { result } = renderHook(
+ () => {
+ const accounts = useBrokerageAccounts();
+ useScreenSync({ screen: "accounts" }, [accounts]);
+ return accounts;
+ },
+ { wrapper: createQueryWrapper().wrapper },
+ );
+ await waitFor(() => expect(result.current.data).toEqual([]));
+ expect(modes).toEqual([null, "observe"]);
+ });
+
+ it("preserves a fresh snapshot and timestamp after a failed force", async () => {
+ auth();
+ const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
+ http.post("*/api/v1/tbank-sync", () =>
+ HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
+ ),
+ );
+ const { result } = renderHook(
+ () => {
+ const accounts = useBrokerageAccounts();
+ return {
+ accounts,
+ sync: useScreenSync({ screen: "accounts" }, [accounts]),
+ };
+ },
+ { wrapper: createQueryWrapper().wrapper },
+ );
+ await waitFor(() => expect(result.current.accounts.data).toEqual([]));
+ await act(() => result.current.sync.refresh());
+ await waitFor(() => expect(result.current.sync.transportError).toBe(true));
+ expect(result.current.accounts.meta).toEqual(saved.meta);
+ });
+});
diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts
new file mode 100644
index 0000000..b57481b
--- /dev/null
+++ b/src/features/tbank-connection/model/screen-sync.ts
@@ -0,0 +1,148 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useRef, useState } from "react";
+import {
+ apiErrorCode,
+ apiQueryClient,
+ invalidateResourceAccess,
+ type paths,
+ useAuthenticatedQueries,
+} from "@/shared/api";
+import { type InvestmentScreen, useTbankSync } from "./sync";
+
+type ResourceMeta =
+ paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
+type ScreenResource = {
+ meta?: ResourceMeta;
+ observe: (version?: string) => Promise;
+};
+const STATUS_INTERVAL_MS = 1000;
+
+function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
+ const mutation = useTbankSync();
+ const client = useQueryClient();
+ const denied = useRef(new Set());
+ const scopeKey = JSON.stringify(scope);
+ const [requested, setRequested] = useState<{
+ scope: string;
+ id: string;
+ } | null>(null);
+ const requestInFlight = useRef(null);
+ const currentScope = useRef(scopeKey);
+ currentScope.current = scopeKey;
+ const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey;
+ const observed = useRef(new Map());
+ const ids = [
+ ...new Set([
+ ...(requested?.scope === scopeKey ? [requested.id] : []),
+ ...resources
+ .flatMap(({ meta }) =>
+ meta ? [meta, ...(meta.dependencies ?? [])] : [],
+ )
+ .flatMap((meta) =>
+ meta?.sync.id &&
+ (meta.sync.state === "running" || meta.sync.state === "queued")
+ ? [meta.sync.id]
+ : [],
+ ),
+ ]),
+ ];
+ const statuses = useAuthenticatedQueries(
+ ids.map((id) => ({
+ ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
+ params: { path: { id } },
+ }),
+ retry: false,
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ refetchInterval: (query: {
+ state: { error: unknown; data?: { state: string } };
+ }) =>
+ query.state.error ||
+ (query.state.data && query.state.data.state !== "running")
+ ? false
+ : STATUS_INTERVAL_MS,
+ })),
+ );
+ useEffect(() => {
+ for (const status of statuses) {
+ if (status.error && !denied.current.has(status.error)) {
+ denied.current.add(status.error);
+ if (apiErrorCode(status.error) !== "NOT_FOUND")
+ invalidateResourceAccess(client, "brokerage", status.error);
+ }
+ }
+ const latest = new Map();
+ for (const status of [...statuses].sort(
+ (a, b) => a.dataUpdatedAt - b.dataUpdatedAt,
+ ))
+ for (const meta of status.data?.resources ?? []) {
+ latest.set(meta.resourceKey, meta);
+ for (const dependency of meta.dependencies ?? [])
+ latest.set(dependency.resourceKey, dependency);
+ }
+ for (const resource of resources) {
+ if (!resource.meta) continue;
+ const changed = [
+ resource.meta,
+ ...(resource.meta.dependencies ?? []),
+ ].flatMap((current) => {
+ const notified = latest.get(current.resourceKey);
+ return notified && notified.stateVersion !== current.stateVersion
+ ? [notified]
+ : [];
+ });
+ if (!changed.length) continue;
+ const notification = JSON.stringify(
+ changed.map((meta) => [meta.resourceKey, meta.stateVersion]),
+ );
+ if (observed.current.get(resource.meta.resourceKey) === notification)
+ continue;
+ observed.current.set(resource.meta.resourceKey, notification);
+ const own = changed.find(
+ (meta) => meta.resourceKey === resource.meta?.resourceKey,
+ );
+ void resource.observe(own?.stateVersion).catch(() => undefined);
+ }
+ }, [client, statuses, resources]);
+ const refreshing =
+ (currentMutation && mutation.isPending) ||
+ statuses.some(
+ (status) => !status.isError && status.data?.state === "running",
+ ) ||
+ resources.some(({ meta }) => {
+ const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
+ return (
+ !status?.isError &&
+ (!status?.data || status.data.state === "running") &&
+ (meta?.sync.state === "running" || meta?.sync.state === "queued")
+ );
+ });
+ const refresh = async () => {
+ if (requestInFlight.current === scopeKey || refreshing) return;
+ requestInFlight.current = scopeKey;
+ try {
+ const accepted = await mutation.mutateAsync({ body: scope });
+ if (
+ currentScope.current === scopeKey &&
+ requestInFlight.current === scopeKey
+ )
+ setRequested({ scope: scopeKey, id: accepted.id });
+ } catch {
+ /* The mutation retains the transport error for presentation. */
+ } finally {
+ if (requestInFlight.current === scopeKey) requestInFlight.current = null;
+ }
+ };
+ return {
+ refresh,
+ refreshing,
+ transportError:
+ (currentMutation && mutation.isError) ||
+ statuses.some((status) => status.isError),
+ sourceError:
+ statuses.some((status) => status.data?.state === "failed") ||
+ resources.some(({ meta }) => meta?.sync.state === "failed"),
+ };
+}
+
+export { useScreenSync };
diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx
new file mode 100644
index 0000000..425e36b
--- /dev/null
+++ b/src/features/tbank-connection/model/sync.test.tsx
@@ -0,0 +1,114 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { HttpResponse, http } from "msw";
+import { describe, expect, it, vi } from "vitest";
+import { apiQueryClient, configureAuthClient } from "@/shared/api";
+import { createQueryWrapper } from "@/test/query";
+import { server } from "@/test/server";
+import {
+ type InvestmentScreen,
+ useTbankSync,
+ useTbankSyncStatus,
+} from "../index";
+import { commitTbankConnectionBoundary } from "./connection-boundary";
+
+describe("sync connection and session boundaries", () => {
+ it.each(["connection", "session"])(
+ "discards pending status and mutation after %s changes",
+ async (boundary) => {
+ let generation = 0;
+ configureAuthClient({
+ generation: () => generation,
+ accessToken: () => "synthetic",
+ refresh: async () => true,
+ clear: vi.fn(),
+ });
+ let release!: () => void;
+ const blocked = new Promise((resolve) => {
+ release = resolve;
+ });
+ const accepted = {
+ id: "work",
+ scope: { screen: "accounts" },
+ now: "2026-09-25T12:00:00Z",
+ };
+ let started = 0;
+ let changed = false;
+ server.use(
+ http.post("*/api/v1/tbank-sync", async () => {
+ started += 1;
+ await blocked;
+ return HttpResponse.json(accepted, { status: 202 });
+ }),
+ http.get("*/api/v1/tbank-sync/:id", async () => {
+ const now = changed ? "2026-09-25T12:01:00Z" : accepted.now;
+ started += 1;
+ await blocked;
+ return HttpResponse.json({
+ ...accepted,
+ now,
+ state: "succeeded",
+ errorCode: null,
+ progress: { total: 1, completed: 1, discovering: false },
+ resources: [],
+ });
+ }),
+ );
+ const { wrapper, queryClient } = createQueryWrapper();
+ const published: string[] = [];
+ const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
+ if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") {
+ const data = event.query.state.data as { now?: string } | undefined;
+ if (data?.now) published.push(data.now);
+ }
+ });
+ const { result } = renderHook(
+ () => ({
+ mutation: useTbankSync(),
+ status: useTbankSyncStatus("work"),
+ }),
+ { wrapper },
+ );
+ const screen: InvestmentScreen = { screen: "accounts" };
+ let mutation!: Promise;
+ act(() => {
+ mutation = result.current.mutation.mutateAsync({ body: screen });
+ });
+ const discarded = expect(mutation).rejects.toMatchObject({
+ name: "AbortError",
+ });
+ await waitFor(() => expect(started).toBe(2));
+ await act(async () => {
+ changed = true;
+ if (boundary === "connection")
+ commitTbankConnectionBoundary(queryClient, {
+ connected: false,
+ connectedAt: null,
+ });
+ else {
+ generation += 1;
+ queryClient.clear();
+ }
+ release();
+ await blocked;
+ });
+ await discarded;
+ expect(result.current.mutation.data).toBeUndefined();
+ const key = apiQueryClient.queryOptions(
+ "get",
+ "/api/v1/tbank-sync/{id}",
+ { params: { path: { id: "work" } } },
+ ).queryKey;
+ // A new request may start after a successful connection boundary; an old result cannot win it.
+ if (boundary === "session")
+ expect(queryClient.getQueryData(key)).toBeUndefined();
+ else
+ await waitFor(() =>
+ expect(queryClient.getQueryData(key)).toMatchObject({
+ now: "2026-09-25T12:01:00Z",
+ }),
+ );
+ expect(published).not.toContain(accepted.now);
+ unsubscribe();
+ },
+ );
+});
diff --git a/src/features/tbank-connection/model/sync.ts b/src/features/tbank-connection/model/sync.ts
new file mode 100644
index 0000000..594c5e3
--- /dev/null
+++ b/src/features/tbank-connection/model/sync.ts
@@ -0,0 +1,24 @@
+import {
+ apiQueryClient,
+ type paths,
+ useAuthenticatedQuery,
+} 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,
+ });
+}
+function useTbankSyncStatus(id: string | null) {
+ return useAuthenticatedQuery({
+ ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
+ params: { path: { id: id ?? "" } },
+ }),
+ enabled: Boolean(id),
+ retry: false,
+ });
+}
+
+export { type InvestmentScreen, useTbankSync, useTbankSyncStatus };
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
similarity index 100%
rename from src/features/tbank-connection/ui/TbankConnectionSection.test.tsx
rename to src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx
similarity index 100%
rename from src/features/tbank-connection/ui/TbankConnectionSection.tsx
rename to src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx
similarity index 100%
rename from src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx
rename to src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx
similarity index 93%
rename from src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
rename to src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx
index dd5c9cc..d090d3c 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx
@@ -1,7 +1,7 @@
import { Alert, Button, Skeleton } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
type TbankConnectionErrorProps = {
retry: () => void;
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts
similarity index 84%
rename from src/features/tbank-connection/ui/TbankConnectionSection.vm.ts
rename to src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts
index 9855647..105bf73 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts
@@ -1,5 +1,5 @@
import { useAuthenticatedQuery } from "@/shared/api";
-import { tbankConnectionQueryOptions } from "../model/connection-boundary";
+import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({
diff --git a/src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx
similarity index 98%
rename from src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx
rename to src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx
index 13e0b41..a796492 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSectionLoaded.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx
@@ -15,7 +15,7 @@ import { apiErrorCode, apiQueryClient } from "@/shared/api";
import {
commitTbankConnectionBoundary,
type TbankConnectionStatus,
-} from "../model/connection-boundary";
+} from "../../model/connection-boundary";
type TbankConnectionSectionLoadedProps = {
status: TbankConnectionStatus;
diff --git a/src/main.tsx b/src/main.tsx
index 1c7e32f..3a2c079 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css";
+import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru";
import "./shared/i18n";
import { App } from "./app";
diff --git a/src/pages/account-events/index.ts b/src/pages/account-events/index.ts
index 73763c3..99b2be1 100644
--- a/src/pages/account-events/index.ts
+++ b/src/pages/account-events/index.ts
@@ -1 +1 @@
-export { AccountEventsPage } from "./ui/AccountEventsPage";
+export { AccountEventsPage } from "./ui/AccountEventsPage/AccountEventsPage";
diff --git a/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx
similarity index 67%
rename from src/pages/account-events/ui/AccountEventsPage.filters.test.tsx
rename to src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx
index b402669..f405623 100644
--- a/src/pages/account-events/ui/AccountEventsPage.filters.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import {
+ fireEvent,
+ render,
+ renderHook,
+ screen,
+ waitFor,
+} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import {
@@ -12,9 +18,11 @@ import {
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
-import { createTestQueryClient } from "@/test/query";
+import { createQueryWrapper, createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountEventsPage } from "./AccountEventsPage";
+import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm";
const Navigation: FC = () => {
const location = useLocation();
@@ -44,12 +52,14 @@ describe("Upcoming event date filters", () => {
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
- return HttpResponse.json({
- accountId,
- calculatedAt: "2099-01-01T00:00:00Z",
- events: [],
- incomeTotalsByCurrency: [],
- });
+ return HttpResponse.json(
+ resource({
+ accountId,
+ calculatedAt: "2099-01-01T00:00:00Z",
+ events: [],
+ incomeTotalsByCurrency: [],
+ }),
+ );
},
),
);
@@ -110,3 +120,45 @@ describe("Upcoming event date filters", () => {
},
);
});
+
+it("retains the previous complete forecast and timestamp when its source refresh fails", async () => {
+ configureAuthClient({
+ accessToken: () => "synthetic",
+ refresh: async () => true,
+ clear: vi.fn(),
+ });
+ const events = [
+ {
+ date: "2026-10-01",
+ displayName: "Synthetic coupon",
+ type: "coupon",
+ projectedAmount: { currency: "RUB", value: "123.45" },
+ },
+ ];
+ const snapshot = resource(
+ { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
+ { sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } },
+ );
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
+ HttpResponse.json(snapshot),
+ ),
+ );
+ const { wrapper: QueryWrapper } = createQueryWrapper();
+ const { result } = renderHook(() => useAccountEventsPageViewModel("all"), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+ await waitFor(() =>
+ expect(result.current).toMatchObject({
+ state: "loaded",
+ events,
+ sourceError: true,
+ refreshError: true,
+ lastSuccessfulAt: snapshot.meta.lastSuccessfulAt,
+ }),
+ );
+});
diff --git a/src/pages/account-events/ui/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
similarity index 87%
rename from src/pages/account-events/ui/AccountEventsPage.test.tsx
rename to src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
index d4a94ba..5312127 100644
--- a/src/pages/account-events/ui/AccountEventsPage.test.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx
@@ -5,6 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { useBrokerageEvents } from "@/entities/brokerage-event";
import { AccountEventsPage } from "./AccountEventsPage";
+const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
+vi.mock("@/features/tbank-connection", () => ({
+ useScreenSync: () => ({
+ refresh,
+ refreshing: false,
+ sourceError: false,
+ transportError: false,
+ }),
+}));
vi.mock("@/entities/brokerage-event", async (importOriginal) => ({
...(await importOriginal()),
useBrokerageEvents: vi.fn(),
@@ -24,12 +33,17 @@ function renderPage() {
describe("AccountEventsPage", () => {
beforeEach(() => {
+ refresh.mockClear();
query.mockReturnValue({
data: {
accountId: "account",
calculatedAt: "2026-08-14T00:00:00Z",
events: [],
},
+ meta: {
+ availability: "complete",
+ sync: { state: "idle", id: null, errorCode: null },
+ },
isError: false,
isLoading: false,
refetch: vi.fn(),
@@ -55,6 +69,10 @@ describe("AccountEventsPage", () => {
it("keeps the loading fallback when no result or account is available", () => {
query.mockReturnValue({
data: undefined,
+ meta: {
+ availability: "complete",
+ sync: { state: "idle", id: null, errorCode: null },
+ },
isError: false,
isLoading: false,
refetch: vi.fn(),
@@ -99,6 +117,10 @@ describe("AccountEventsPage", () => {
},
],
},
+ meta: {
+ availability: "complete",
+ sync: { state: "idle", id: null, errorCode: null },
+ },
isError: false,
isLoading: false,
refetch: vi.fn(),
@@ -145,12 +167,16 @@ describe("AccountEventsPage", () => {
,
);
screen.getByRole("button", { name: "Повторить" }).click();
- expect(refetch).toHaveBeenCalledOnce();
+ expect(refresh).toHaveBeenCalledOnce();
});
it("shows a structural busy state before events are available", () => {
query.mockReturnValue({
data: undefined,
+ meta: {
+ availability: "complete",
+ sync: { state: "idle", id: null, errorCode: null },
+ },
isError: false,
isLoading: true,
refetch: vi.fn(),
diff --git a/src/pages/account-events/ui/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx
similarity index 100%
rename from src/pages/account-events/ui/AccountEventsPage.tsx
rename to src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx
diff --git a/src/pages/account-events/ui/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
similarity index 74%
rename from src/pages/account-events/ui/AccountEventsPage.vm.ts
rename to src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
index 9737804..1cd4d37 100644
--- a/src/pages/account-events/ui/AccountEventsPage.vm.ts
+++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts
@@ -4,6 +4,7 @@ import {
type BrokerageEvent,
useBrokerageEvents,
} from "@/entities/brokerage-event";
+import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
@@ -11,6 +12,9 @@ type AccountEventsPageViewModel =
| {
state: "loaded";
events: BrokerageEvent[];
+ sourceError?: boolean;
+ lastSuccessfulAt?: string | null;
+ partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
@@ -41,22 +45,34 @@ function useAccountEventsPageViewModel(
...(appliedFrom ? { from: appliedFrom } : {}),
...(appliedTo ? { to: appliedTo } : {}),
});
+ const sync = useScreenSync(
+ {
+ screen: "events",
+ accountId,
+ ...(appliedFrom ? { from: appliedFrom } : {}),
+ ...(appliedTo ? { to: appliedTo } : {}),
+ },
+ [events],
+ );
if (events.isLoading) return { state: "loading" };
- if (events.isError && !events.data)
+ if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
retry: () => {
- void events.refetch();
+ void sync.refresh();
},
};
if (!events.data) return { state: "loading" };
return {
state: "loaded",
events: events.data.events,
- refreshing: events.isFetching,
- refreshError: events.isError,
+ sourceError: sync.sourceError,
+ lastSuccessfulAt: events.meta?.lastSuccessfulAt,
+ partial: events.meta?.availability !== "complete",
+ refreshing: events.isFetching || sync.refreshing,
+ refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => {
- void events.refetch();
+ void sync.refresh();
},
draftFrom,
draftTo,
diff --git a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx
similarity index 86%
rename from src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
rename to src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx
index 5e09eb9..f3cec28 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageEmpty.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx
@@ -1,6 +1,6 @@
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
const AccountEventsPageEmpty: FC = () => {
const { t } = useTranslation();
diff --git a/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
similarity index 91%
rename from src/pages/account-events/ui/components/AccountEventsPageError.view.tsx
rename to src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
index ae03f49..95d463e 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageError.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
type AccountEventsPageErrorProps = { retry: () => void };
diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
similarity index 77%
rename from src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx
rename to src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
index 518706c..5d82878 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageLoaded.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx
@@ -11,6 +11,7 @@ import { DatePickerInput } from "@mantine/dates";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { BrokerageEventCard } from "@/entities/brokerage-event";
+import { formatSnapshotTime } from "@/shared/lib";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view";
@@ -23,13 +24,30 @@ const AccountEventsPageLoaded: FC = (props) => {
const { t } = useTranslation();
return (
+ {props.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(props.lastSuccessfulAt),
+ })}
+
+ ) : null}
{t("events.title")}
+
+ {t("investment.refresh")}
+
+ {props.partial ? (
+ {t("investment.partial")}
+ ) : null}
{props.refreshing ? (
) : null}
{props.refreshError ? (
-
- {t("accounts.refreshError")}
+
+ {t(
+ props.sourceError
+ ? "investment.syncFailed"
+ : "accounts.refreshError",
+ )}
{t("accounts.retry")}
@@ -80,9 +98,9 @@ const AccountEventsPageLoaded: FC = (props) => {
/>
))}
- ) : (
+ ) : !props.partial ? (
- )}
+ ) : null}
);
};
diff --git a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx
similarity index 93%
rename from src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
rename to src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx
index 0370db8..0f04cca 100644
--- a/src/pages/account-events/ui/components/AccountEventsPageLoading.view.tsx
+++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Group, Skeleton } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
diff --git a/src/pages/account-income/index.ts b/src/pages/account-income/index.ts
index e93bb37..05a690e 100644
--- a/src/pages/account-income/index.ts
+++ b/src/pages/account-income/index.ts
@@ -1 +1 @@
-export { AccountIncomePage } from "./ui/AccountIncomePage";
+export { AccountIncomePage } from "./ui/AccountIncomePage/AccountIncomePage";
diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts
new file mode 100644
index 0000000..1513a50
--- /dev/null
+++ b/src/pages/account-income/model/period.ts
@@ -0,0 +1,46 @@
+type IncomePeriod = { from: string; to: string };
+type IncomeForm = IncomePeriod;
+
+const datePattern = /^\d{4}-\d{2}-\d{2}$/;
+
+function formatDraftDate(value: string): string {
+ const [year, month, day] = value.split("-");
+ return `${day}.${month}.${year}`;
+}
+
+function parseDraftDate(value: string): string | null {
+ if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
+ const [day, month, year] = value.split(".");
+ const date = `${year}-${month}-${day}`;
+ return isDate(date) ? date : null;
+}
+
+function isDate(value: string): boolean {
+ if (!datePattern.test(value)) return false;
+ const parsed = new Date(`${value}T00:00:00Z`);
+ return (
+ Number.isFinite(parsed.valueOf()) &&
+ parsed.toISOString().slice(0, 10) === value
+ );
+}
+
+function moscowDate(value: Date): string {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: "Europe/Moscow",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).formatToParts(value);
+ const part = (type: string) =>
+ parts.find((item) => item.type === type)?.value;
+ return `${part("year")}-${part("month")}-${part("day")}`;
+}
+
+export {
+ formatDraftDate,
+ type IncomeForm,
+ type IncomePeriod,
+ isDate,
+ moscowDate,
+ parseDraftDate,
+};
diff --git a/src/pages/account-income/ui/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css
similarity index 100%
rename from src/pages/account-income/ui/AccountIncomePage.module.css
rename to src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css
diff --git a/src/pages/account-income/ui/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
similarity index 84%
rename from src/pages/account-income/ui/AccountIncomePage.test.tsx
rename to src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
index 3a587f3..51056cc 100644
--- a/src/pages/account-income/ui/AccountIncomePage.test.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx
@@ -1,3 +1,4 @@
+import { resource } from "@/test/resource";
import "dayjs/locale/ru";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
@@ -23,8 +24,9 @@ import type { IncomeAnalytics } from "@/entities/income";
import { configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
+import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
-import { isDate, toIncomeDisplayModel } from "./AccountIncomePage.vm";
+import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
@@ -153,7 +155,7 @@ describe("AccountIncomePage", () => {
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
- return HttpResponse.json(analytics);
+ return HttpResponse.json(resource(analytics));
},
),
);
@@ -235,7 +237,7 @@ describe("AccountIncomePage", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
- HttpResponse.json(response),
+ HttpResponse.json(resource(response)),
),
);
renderPage();
@@ -283,7 +285,7 @@ describe("AccountIncomePage", () => {
])("shows a local error for %s", async (_name, response) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
- HttpResponse.json(response),
+ HttpResponse.json(resource(response)),
),
);
renderPage();
@@ -298,13 +300,15 @@ describe("AccountIncomePage", () => {
it("accepts an empty analytics response", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
- HttpResponse.json({
- ...analytics,
- totalsByCurrency: [],
- buckets: [],
- instruments: [],
- entries: [],
- }),
+ HttpResponse.json(
+ resource({
+ ...analytics,
+ totalsByCurrency: [],
+ buckets: [],
+ instruments: [],
+ entries: [],
+ }),
+ ),
),
);
renderPage();
@@ -346,7 +350,7 @@ describe("AccountIncomePage", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
- HttpResponse.json(response),
+ HttpResponse.json(resource(response)),
),
);
renderPage();
@@ -368,13 +372,15 @@ describe("AccountIncomePage", () => {
const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 }));
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
- HttpResponse.json({
- ...analytics,
- yearComparison: {
- period: { from: "2025-08-01", to: "2025-08-27" },
- totalsByCurrency: [comparison("RUB")],
- },
- }),
+ HttpResponse.json(
+ resource({
+ ...analytics,
+ yearComparison: {
+ period: { from: "2025-08-01", to: "2025-08-27" },
+ totalsByCurrency: [comparison("RUB")],
+ },
+ }),
+ ),
),
http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest),
);
@@ -391,13 +397,16 @@ describe("AccountIncomePage", () => {
expect(screen.queryByText("Динамика")).not.toBeInTheDocument();
expect(eventsRequest).not.toHaveBeenCalled();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument();
- const selected = screen.getByRole("region", {
+ const year = screen.getByRole("region", { name: "Годом ранее" });
+ const selected = within(year).getByRole("region", {
name: "01.08.2026 – 27.08.2026",
});
expect(
screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }),
).toBeInTheDocument();
- expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "Предыдущий период" }),
+ ).toBeVisible();
expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument();
expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument();
expect(
@@ -408,7 +417,7 @@ describe("AccountIncomePage", () => {
for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) {
expect(within(selected).getByText(amount)).toBeInTheDocument();
}
- const difference = screen.getByRole("region", { name: "Разница" });
+ const difference = within(year).getByRole("region", { name: "Разница" });
for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) {
expect(within(difference).getByText(amount)).toBeInTheDocument();
}
@@ -424,7 +433,18 @@ describe("AccountIncomePage", () => {
it("shows the unavailable year state without falling back to the previous period", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
- HttpResponse.json(analytics),
+ HttpResponse.json(
+ resource(analytics, {
+ dependencies: [
+ resource(analytics).meta,
+ resource(analytics).meta,
+ {
+ ...resource(null).meta,
+ unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
+ },
+ ],
+ }),
+ ),
),
);
renderPage();
@@ -432,9 +452,11 @@ describe("AccountIncomePage", () => {
await screen.findByText("Сравнение с годом ранее недоступно."),
).toBeInTheDocument();
expect(
- screen.queryByRole("region", { name: "Разница" }),
+ screen.queryByRole("region", { name: "Годом ранее" }),
).not.toBeInTheDocument();
- expect(screen.queryByText(/Предыдущий период/)).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("region", { name: "Предыдущий период" }),
+ ).toBeVisible();
});
it("shows a recoverable realized error", async () => {
@@ -536,13 +558,15 @@ function respondWithRequestedPeriod(requests: string[]) {
({ request }) => {
const url = new URL(request.url);
requests.push(url.search);
- return HttpResponse.json({
- ...analytics,
- period: {
- from: url.searchParams.get("from"),
- to: url.searchParams.get("to"),
- },
- });
+ return HttpResponse.json(
+ resource({
+ ...analytics,
+ period: {
+ from: url.searchParams.get("from"),
+ to: url.searchParams.get("to"),
+ },
+ }),
+ );
},
),
);
@@ -668,7 +692,32 @@ describe("income period and snapshot boundary", () => {
await screen.findByText(/Показан последний загруженный результат/),
).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
- respondWithRequestedPeriod([]);
+ server.use(
+ http.post("*/api/v1/tbank-sync", () =>
+ HttpResponse.json(
+ {
+ id: "income",
+ scope: { screen: "income", accountId: "account-1" },
+ now: "2026-09-25T00:00:00Z",
+ },
+ { status: 202 },
+ ),
+ ),
+ http.get("*/api/v1/tbank-sync/:id", () =>
+ HttpResponse.json({
+ id: "income",
+ scope: null,
+ now: "2026-09-25T00:00:00Z",
+ state: "succeeded",
+ errorCode: null,
+ progress: { total: 1, completed: 1, discovering: false },
+ resources: [resource(null, { stateVersion: "2" }).meta],
+ }),
+ ),
+ 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(
@@ -693,10 +742,12 @@ describe("income period and snapshot boundary", () => {
"*/api/v1/brokerage-accounts/account-1/income-analytics",
async () => {
await pending;
- return HttpResponse.json({
- ...analytics,
- period: { from: "2026-08-02", to: "2026-08-27" },
- });
+ return HttpResponse.json(
+ resource({
+ ...analytics,
+ period: { from: "2026-08-02", to: "2026-08-27" },
+ }),
+ );
},
),
);
@@ -777,3 +828,64 @@ describe("income period and snapshot boundary", () => {
expect(screen.getAllByText("Купон")).toHaveLength(6);
});
});
+
+it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
+ HttpResponse.json(
+ resource({ ...analytics, previous: null, yearComparison: null }),
+ ),
+ ),
+ );
+ renderPage();
+ expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible();
+ expect(
+ screen.queryByRole("region", { name: "Год назад" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0);
+ expect(
+ screen.getAllByText("Период сравнения ещё не загружен полностью."),
+ ).toHaveLength(2);
+ expect(
+ screen.queryByText("Сравнение с годом ранее недоступно."),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("region", { name: "Разница" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Период")).toBeVisible();
+});
+
+it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
+ HttpResponse.json(
+ resource(analytics, {
+ dependencies: [
+ resource(analytics).meta,
+ resource(analytics).meta,
+ {
+ ...resource(null).meta,
+ sync: {
+ id: null,
+ state: "failed",
+ errorCode: "TBANK_UNAVAILABLE",
+ },
+ },
+ ],
+ }),
+ ),
+ ),
+ );
+ renderPage();
+ expect(
+ await screen.findByRole("region", { name: "Предыдущий период" }),
+ ).toBeVisible();
+ expect(
+ screen.getByText(
+ "Не удалось загрузить период сравнения. Повторите обновление.",
+ ),
+ ).toBeVisible();
+ expect(
+ screen.queryByRole("region", { name: "Годом ранее" }),
+ ).not.toBeInTheDocument();
+});
diff --git a/src/pages/account-income/ui/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx
similarity index 100%
rename from src/pages/account-income/ui/AccountIncomePage.tsx
rename to src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx
diff --git a/src/pages/account-income/ui/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
similarity index 71%
rename from src/pages/account-income/ui/AccountIncomePage.vm.ts
rename to src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
index 9237330..81bab28 100644
--- a/src/pages/account-income/ui/AccountIncomePage.vm.ts
+++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts
@@ -4,15 +4,30 @@ import { useForm } from "react-hook-form";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income";
+import { useScreenSync } from "@/features/tbank-connection";
import { validationMessage } from "@/shared/i18n";
import { formatDate } from "@/shared/lib";
+import {
+ formatDraftDate,
+ type IncomeForm,
+ type IncomePeriod,
+ isDate,
+ moscowDate,
+ parseDraftDate,
+} from "../../model/period";
const ISO_MONTH_LENGTH = 7;
-type IncomePeriod = { from: string; to: string };
-type IncomeForm = IncomePeriod;
+type IncomeMeta = ReturnType["meta"];
+type ComparisonNotice =
+ | "comparisonPending"
+ | "comparisonFailed"
+ | "yearUnavailable";
type IncomeDisplayModel = {
+ previousComparison: IncomeDisplayModel["comparison"];
+ previousNotice: ComparisonNotice;
+ yearNotice: ComparisonNotice;
entries: { key: string; operation: IncomeAnalytics["entries"][number] }[];
comparison: {
selectedPeriod: string;
@@ -33,6 +48,9 @@ type AccountIncomePageViewModel =
analytics: IncomeAnalytics;
display: IncomeDisplayModel;
formRevision: number;
+ sourceError?: boolean;
+ lastSuccessfulAt?: string | null;
+ partial?: boolean;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
@@ -41,7 +59,6 @@ type AccountIncomePageViewModel =
onSubmit: (values: IncomeForm) => void;
};
-const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const draftDate = z
.string()
.refine((value) => parseDraftDate(value) !== null, {
@@ -86,6 +103,10 @@ const useAccountIncomePageViewModel = (
});
const [formRevision, setFormRevision] = useState(0);
const analytics = useIncomeAnalytics(accountId, period);
+ const sync = useScreenSync(
+ { screen: "income", accountId, from: period.from, to: period.to },
+ [analytics],
+ );
useEffect(() => {
if (!validPeriod(requested)) setSearchParams(period, { replace: true });
@@ -108,39 +129,36 @@ const useAccountIncomePageViewModel = (
setSearchParams(next, { replace: true });
};
if (analytics.isLoading || !analytics.data) {
- if (analytics.isError)
- return { state: "error", retry: () => analytics.refetch(), period };
+ if (analytics.isError || analytics.meta?.sync.state === "failed")
+ return { state: "error", retry: sync.refresh, period };
return { state: "loading" };
}
- const display = toIncomeDisplayModel(analytics.data);
+ const display = toIncomeDisplayModel(analytics.data, analytics.meta);
if (!display)
- return { state: "error", retry: () => analytics.refetch(), period };
+ return {
+ state: "error",
+ retry: () => {
+ void analytics.observe();
+ },
+ period,
+ };
return {
state: "loaded",
analytics: analytics.data,
display,
formRevision,
- refreshing: analytics.isFetching,
- refreshError: analytics.isError,
- retry: () => analytics.refetch(),
+ sourceError: sync.sourceError,
+ lastSuccessfulAt: analytics.meta?.lastSuccessfulAt,
+ partial: analytics.meta?.availability !== "complete",
+ refreshing: analytics.isFetching || sync.refreshing,
+ refreshError: analytics.isError || sync.sourceError || sync.transportError,
+ retry: sync.refresh,
form,
onSubmit,
onReset,
};
};
-function formatDraftDate(value: string): string {
- const [year, month, day] = value.split("-");
- return `${day}.${month}.${year}`;
-}
-
-function parseDraftDate(value: string): string | null {
- if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null;
- const [day, month, year] = value.split(".");
- const date = `${year}-${month}-${day}`;
- return isDate(date) ? date : null;
-}
-
function draftPeriod(period: IncomePeriod): IncomeForm {
return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) };
}
@@ -163,17 +181,9 @@ function validPeriod(period: IncomePeriod): period is IncomePeriod {
);
}
-function isDate(value: string): boolean {
- if (!datePattern.test(value)) return false;
- const parsed = new Date(`${value}T00:00:00Z`);
- return (
- Number.isFinite(parsed.valueOf()) &&
- parsed.toISOString().slice(0, 10) === value
- );
-}
-
function toIncomeDisplayModel(
analytics: IncomeAnalytics,
+ meta?: IncomeMeta,
): IncomeDisplayModel | null {
const instruments = new Map<
string,
@@ -226,33 +236,53 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
+ // The income endpoint returns selected, previous and year dependencies in order.
+ const [, previous, year] = meta?.dependencies ?? [];
return {
entries,
- comparison: analytics.yearComparison
- ? {
- selectedPeriod: formatComparisonPeriod(analytics.period),
- comparedPeriod: formatComparisonPeriod(
- analytics.yearComparison.period,
- ),
- values: analytics.yearComparison.totalsByCurrency.map((value) => ({
- ...value,
- percentages: {
- total: formatIncomeChange(
- value.difference.total.value,
- value.compared.total.value,
- ),
- coupon: formatIncomeChange(
- value.difference.coupon.value,
- value.compared.coupon.value,
- ),
- dividend: formatIncomeChange(
- value.difference.dividend.value,
- value.compared.dividend.value,
- ),
- },
- })),
- }
- : null,
+ previousComparison: comparisonDisplay(analytics.period, analytics.previous),
+ comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
+ previousNotice: comparisonNotice(previous),
+ yearNotice: comparisonNotice(year),
+ };
+}
+
+function comparisonNotice(
+ dependency:
+ | NonNullable["dependencies"]>[number]
+ | undefined,
+): ComparisonNotice {
+ if (dependency?.unavailableReason) return "yearUnavailable";
+ return dependency?.sync.state === "failed"
+ ? "comparisonFailed"
+ : "comparisonPending";
+}
+
+function comparisonDisplay(
+ period: IncomePeriod,
+ comparison: IncomeAnalytics["yearComparison"],
+): IncomeDisplayModel["comparison"] {
+ if (!comparison) return null;
+ return {
+ selectedPeriod: formatComparisonPeriod(period),
+ comparedPeriod: formatComparisonPeriod(comparison.period),
+ values: comparison.totalsByCurrency.map((value) => ({
+ ...value,
+ percentages: {
+ total: formatIncomeChange(
+ value.difference.total.value,
+ value.compared.total.value,
+ ),
+ coupon: formatIncomeChange(
+ value.difference.coupon.value,
+ value.compared.coupon.value,
+ ),
+ dividend: formatIncomeChange(
+ value.difference.dividend.value,
+ value.compared.dividend.value,
+ ),
+ },
+ })),
};
}
@@ -276,26 +306,9 @@ function formatIncomeChange(
}).format(ratio);
}
-function moscowDate(value: Date): string {
- const parts = new Intl.DateTimeFormat("en-CA", {
- timeZone: "Europe/Moscow",
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- }).formatToParts(value);
- const part = (type: string) =>
- parts.find((item) => item.type === type)?.value;
- return `${part("year")}-${part("month")}-${part("day")}`;
-}
-
export {
type AccountIncomePageViewModel,
- formatDraftDate,
type IncomeDisplayModel,
- type IncomeForm,
- isDate,
- moscowDate,
- parseDraftDate,
toIncomeDisplayModel,
useAccountIncomePageViewModel,
};
diff --git a/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
similarity index 92%
rename from src/pages/account-income/ui/components/AccountIncomePageError.view.tsx
rename to src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
index 3bf8094..e125d81 100644
--- a/src/pages/account-income/ui/components/AccountIncomePageError.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
type ErrorProps = Extract;
diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
similarity index 61%
rename from src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx
rename to src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
index 4e36b1e..9d2da9f 100644
--- a/src/pages/account-income/ui/components/AccountIncomePageLoaded.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx
@@ -7,15 +7,16 @@ import {
Text,
Title,
} from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { FormProvider } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
+import { formatSnapshotTime } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css";
import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm";
import { IncomeComparisons } from "./IncomeComparisons.view";
import { IncomePayments } from "./IncomePayments.view";
-import { IncomePeriodForm } from "./IncomePeriodForm.view";
+import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view";
import { IncomeSummary } from "./IncomeSummary.view";
type LoadedProps = Extract;
@@ -34,7 +35,17 @@ const AccountIncomePageLoaded: FC = (props) => {
const { t } = useTranslation();
return (
+ {props.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(props.lastSuccessfulAt),
+ })}
+
+ ) : null}
{t("income.title")}
+
+ {t("investment.refresh")}
+
= (props) => {
) : null}
{refreshError ? (
-
+
- {t("income.refreshError")}
+
+ {t(
+ props.sourceError
+ ? "investment.syncFailed"
+ : "income.refreshError",
+ )}
+
{t("income.retry")}
) : null}
- {analytics.totalsByCurrency.length ? (
+ {props.partial ? (
+ {t("investment.partial")}
+ ) : analytics.totalsByCurrency.length ? (
) : (
= (props) => {
/>
)}
{t("income.taxBoundary")}
-
+
+
);
diff --git a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx
similarity index 94%
rename from src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx
rename to src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx
index bf93469..f465dbb 100644
--- a/src/pages/account-income/ui/components/AccountIncomePageLoading.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Paper, Skeleton, Stack } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
import classes from "../AccountIncomePage.module.css";
const AccountIncomePageLoading: FC = () => {
diff --git a/src/pages/account-income/ui/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx
similarity index 89%
rename from src/pages/account-income/ui/components/IncomeComparisons.view.tsx
rename to src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx
index a44473e..8e32d4e 100644
--- a/src/pages/account-income/ui/components/IncomeComparisons.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx
@@ -5,8 +5,14 @@ import { formatMoney } from "@/shared/lib";
import classes from "../AccountIncomePage.module.css";
import type { IncomeDisplayModel } from "../AccountIncomePage.vm";
-type IncomeComparisonsProps = { comparison: IncomeDisplayModel["comparison"] };
-type ComparisonProps = NonNullable;
+type IncomeComparisonsProps = {
+ comparison: IncomeDisplayModel["comparison"];
+ title: string;
+ notice: string;
+};
+type ComparisonProps = NonNullable & {
+ title: string;
+};
type ComparisonValuesProps = {
kind: "selected" | "compared" | "difference";
label: string;
@@ -15,16 +21,15 @@ type ComparisonValuesProps = {
};
const IncomeComparisons: FC = (props) => {
- const { t } = useTranslation();
return (
- {t("income.yearAgo")}
+ {props.title}
{props.comparison ? (
-
+
) : (
-
- {t("income.yearUnavailable")}
+
+ {props.notice}
)}
@@ -35,7 +40,7 @@ const IncomeComparisons: FC = (props) => {
const Comparison: FC = (props) => {
const { t } = useTranslation();
return (
-
+
{props.values.map((value) => (
= (props) => {
+ const methods = useForm({
+ defaultValues: period,
+ errors: props.invalid ? errors : undefined,
+ });
+ const { isSubmitSuccessful } = useFormState({ control: methods.control });
+ const submitted = isSubmitSuccessful ? methods.getValues() : null;
+ return (
+
+ undefined)}
+ onReset={() => {
+ methods.reset(period);
+ }}
+ />
+
+
+ );
+};
+
+const meta = {
+ title: "Product/IncomePeriodForm",
+ component: PeriodExample,
+ tags: ["autodocs"],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Изолированная форма с синтетическим периодом. Submit показывает значения локально; reset возвращает fixture. Ошибка задана явно: query, URL и production validation остаются у page VM, API и router providers не используются.",
+ },
+ },
+ },
+ render: () => ,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
+export const ValidationError: Story = {
+ render: () => ,
+};
diff --git a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx
similarity index 96%
rename from src/pages/account-income/ui/components/IncomePeriodForm.view.tsx
rename to src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx
index ae78c66..c527fef 100644
--- a/src/pages/account-income/ui/components/IncomePeriodForm.view.tsx
+++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx
@@ -3,13 +3,13 @@ import { DatePickerInput } from "@mantine/dates";
import type { FC, FormEventHandler } from "react";
import { useController, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
-import classes from "../AccountIncomePage.module.css";
import {
formatDraftDate,
type IncomeForm,
moscowDate,
parseDraftDate,
-} from "../AccountIncomePage.vm";
+} from "../../../../model/period";
+import classes from "../../AccountIncomePage.module.css";
type IncomePeriodFormProps = {
onSubmit: FormEventHandler;
diff --git a/src/pages/account-income/ui/components/IncomeSummary.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeSummary.view.tsx
similarity index 100%
rename from src/pages/account-income/ui/components/IncomeSummary.view.tsx
rename to src/pages/account-income/ui/AccountIncomePage/components/IncomeSummary.view.tsx
diff --git a/src/pages/account-operations/index.ts b/src/pages/account-operations/index.ts
index 148dd00..d888f2d 100644
--- a/src/pages/account-operations/index.ts
+++ b/src/pages/account-operations/index.ts
@@ -1 +1 @@
-export { AccountOperationsPage } from "./ui/AccountOperationsPage";
+export { AccountOperationsPage } from "./ui/AccountOperationsPage/AccountOperationsPage";
diff --git a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
similarity index 76%
rename from src/pages/account-operations/ui/AccountOperationsPage.test.tsx
rename to src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
index 13ecbeb..77840f7 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage.test.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx
@@ -10,6 +10,7 @@ import {
} from "@/entities/broker-operation";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountOperationsPage } from "./AccountOperationsPage";
@@ -39,11 +40,13 @@ function configureTestAuth() {
});
}
-function renderPage() {
+function renderPage(search = "") {
return render(
-
+
{
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
- return HttpResponse.json({
- accountId: "account-1",
- items: [operation],
- nextCursor: url.searchParams.get("cursor") ? null : "next-1",
- hasNext: !url.searchParams.get("cursor"),
- observedAt: "2026-08-14T00:00:00Z",
- });
+ return HttpResponse.json(
+ resource({
+ accountId: "account-1",
+ items: [operation],
+ nextCursor: url.searchParams.get("cursor") ? null : "next-1",
+ hasNext: !url.searchParams.get("cursor"),
+ observedAt: "2026-08-14T00:00:00Z",
+ }),
+ );
},
),
);
@@ -229,13 +234,15 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
- return HttpResponse.json({
- accountId: "account-1",
- items: [operation],
- nextCursor: null,
- hasNext: false,
- observedAt: "2026-08-14T00:00:00Z",
- });
+ return HttpResponse.json(
+ resource({
+ accountId: "account-1",
+ items: [operation],
+ nextCursor: null,
+ hasNext: false,
+ observedAt: "2026-08-14T00:00:00Z",
+ }),
+ );
},
),
);
@@ -262,13 +269,15 @@ describe("AccountOperationsPage", () => {
({ request }) => {
const url = new URL(request.url);
searches.push(url.search);
- return HttpResponse.json({
- accountId: "account-1",
- items: [operation],
- nextCursor: null,
- hasNext: false,
- observedAt: "2026-08-14T00:00:00Z",
- });
+ return HttpResponse.json(
+ resource({
+ accountId: "account-1",
+ items: [operation],
+ nextCursor: null,
+ hasNext: false,
+ observedAt: "2026-08-14T00:00:00Z",
+ }),
+ );
},
),
);
@@ -301,3 +310,58 @@ describe("AccountOperationsPage", () => {
).toBeInTheDocument();
});
});
+
+it("does not label incomplete empty operations as a confirmed empty period", async () => {
+ configureTestAuth();
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
+ HttpResponse.json(
+ resource(
+ {
+ accountId: "account-1",
+ items: [],
+ hasNext: false,
+ nextCursor: null,
+ },
+ { availability: "partial" },
+ ),
+ ),
+ ),
+ );
+ renderPage();
+ expect(await screen.findByText("Период загружен не полностью")).toBeVisible();
+ expect(screen.queryByText("Операций нет")).not.toBeInTheDocument();
+});
+
+it("recovers an invalid cursor at the first page without clearing applied filters", async () => {
+ configureTestAuth();
+ const searches: URLSearchParams[] = [];
+ server.use(
+ http.get(
+ "*/api/v1/brokerage-accounts/:accountId/operations",
+ ({ request }) => {
+ const params = new URL(request.url).searchParams;
+ searches.push(params);
+ if (params.has("cursor"))
+ return HttpResponse.json(
+ { code: "INVALID_OPERATIONS_CURSOR" },
+ { status: 400 },
+ );
+ return HttpResponse.json(
+ resource({
+ items: [operation],
+ hasNext: true,
+ nextCursor: "old-cursor",
+ }),
+ );
+ },
+ ),
+ );
+ renderPage("?from=2026-08-01&to=2026-08-14&categories=trade");
+ fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
+ fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
+ await waitFor(() => expect(searches.length).toBe(3));
+ expect(searches[2].get("cursor")).toBeNull();
+ for (const key of ["from", "to", "categories"])
+ expect(searches[2].get(key)).toBe(searches[0].get(key));
+});
diff --git a/src/pages/account-operations/ui/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx
similarity index 100%
rename from src/pages/account-operations/ui/AccountOperationsPage.tsx
rename to src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx
diff --git a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
similarity index 86%
rename from src/pages/account-operations/ui/AccountOperationsPage.vm.ts
rename to src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
index 41341d3..c168899 100644
--- a/src/pages/account-operations/ui/AccountOperationsPage.vm.ts
+++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts
@@ -9,6 +9,7 @@ import {
useBrokerageOperations,
} from "@/entities/broker-operation";
+import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
@@ -16,6 +17,10 @@ type AccountOperationsPageViewModel =
| { state: "error"; retry: () => void }
| {
state: "loaded";
+ sourceError?: boolean;
+ lastSuccessfulAt?: string | null;
+ partial?: boolean;
+ historyLoading?: boolean;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
@@ -82,6 +87,10 @@ const useAccountOperationsPageVm = (
...firstPageQuery,
...(cursor ? { cursor } : {}),
});
+ const sync = useScreenSync(
+ { screen: "operations", accountId, ...firstPageQuery },
+ [operations],
+ );
const resetQueryKey = brokerageOperationsQueryOptions(accountId, {
limit: 100,
}).queryKey;
@@ -95,12 +104,12 @@ const useAccountOperationsPageVm = (
exact: true,
});
resetCursors();
- } else void operations.refetch();
+ } else void sync.refresh();
};
if (operations.isLoading) return { state: "loading" };
if (
- operations.isError &&
+ (operations.isError || operations.meta?.sync.state === "failed") &&
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
@@ -115,8 +124,14 @@ const useAccountOperationsPageVm = (
return {
state: "loaded",
- refreshing: operations.isFetching,
- refreshError: operations.isError,
+ sourceError: sync.sourceError,
+ lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
+ partial: operations.meta?.availability !== "complete",
+ historyLoading:
+ operations.meta?.coverage?.historyState === "loading" ||
+ operations.meta?.coverage?.historyState === "partial",
+ refreshing: operations.isFetching || sync.refreshing,
+ refreshError: operations.isError || sync.sourceError || sync.transportError,
retry,
draftFrom,
draftTo,
diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx
similarity index 93%
rename from src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx
rename to src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx
index c5987d6..ace026c 100644
--- a/src/pages/account-operations/ui/components/AccountOperationsPageError.view.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx
@@ -1,7 +1,7 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageErrorProps = Extract<
diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
similarity index 80%
rename from src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx
rename to src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
index 2f02397..077f6ac 100644
--- a/src/pages/account-operations/ui/components/AccountOperationsPageLoaded.view.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx
@@ -5,9 +5,11 @@ import {
MultiSelect,
Progress,
Stack,
+ Text,
Title,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -15,7 +17,7 @@ import {
type OperationCategory,
operationCategories,
} from "@/entities/broker-operation";
-import { StateView } from "@/shared/ui";
+import { formatSnapshotTime } from "@/shared/lib";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
type AccountOperationsPageLoadedProps = Extract<
@@ -59,13 +61,31 @@ const AccountOperationsPageLoaded: FC = (
return (
+ {props.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(props.lastSuccessfulAt),
+ })}
+
+ ) : null}
{t("ledger.title")}
+
+ {t("investment.refresh")}
+
+ {props.partial ? (
+ {t("investment.partial")}
+ ) : null}
+ {props.historyLoading ? {t("investment.history")} : null}
{props.refreshing ? (
) : null}
{props.refreshError ? (
-
- {t("accounts.refreshError")}
+
+ {t(
+ props.sourceError
+ ? "investment.syncFailed"
+ : "accounts.refreshError",
+ )}
{t("accounts.retry")}
@@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC = (
operation={operation}
/>
))
- ) : (
+ ) : !props.partial ? (
- )}
+ ) : null}
{hasNext || hasPrevious ? (
diff --git a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx
similarity index 94%
rename from src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx
rename to src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx
index df54e8e..63b8310 100644
--- a/src/pages/account-operations/ui/components/AccountOperationsPageLoading.view.tsx
+++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Group, Skeleton, Stack } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
const skeletonRowIds = ["first", "second", "third", "fourth"];
diff --git a/src/pages/account/index.ts b/src/pages/account/index.ts
index e30edc3..fdc88c4 100644
--- a/src/pages/account/index.ts
+++ b/src/pages/account/index.ts
@@ -1 +1 @@
-export { AccountPage } from "./ui/AccountPage";
+export { AccountPage } from "./ui/AccountPage/AccountPage";
diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage/AccountPage.module.css
similarity index 96%
rename from src/pages/account/ui/AccountPage.module.css
rename to src/pages/account/ui/AccountPage/AccountPage.module.css
index f5aa21c..c097c31 100644
--- a/src/pages/account/ui/AccountPage.module.css
+++ b/src/pages/account/ui/AccountPage/AccountPage.module.css
@@ -63,11 +63,11 @@
}
.positive {
- color: var(--mantine-color-green-6);
+ color: var(--wl-color-positive);
}
.negative {
- color: var(--mantine-color-red-6);
+ color: var(--wl-color-negative);
}
.expandedCard {
diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
similarity index 92%
rename from src/pages/account/ui/AccountPage.test.tsx
rename to src/pages/account/ui/AccountPage/AccountPage.test.tsx
index 72c9c77..22ede2a 100644
--- a/src/pages/account/ui/AccountPage.test.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx
@@ -14,6 +14,7 @@ import type { BrokerageEvent } from "@/entities/brokerage-event";
import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountPage } from "./AccountPage";
import type {
@@ -24,7 +25,7 @@ import { toAccountPagePositionSections } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
-import { AccountPageLoading } from "./components/AccountPageLoading.view";
+import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
function renderPage(ui: React.ReactNode) {
const queryClient = createTestQueryClient();
@@ -144,27 +145,31 @@ describe("AccountPage states", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
- HttpResponse.json({
- ...portfolio,
- positions:
- accountId === "all"
- ? ["account-1", "account-2"].map((id) => ({
- ...position,
- account: { id, name: id },
- }))
- : [position],
- }),
+ HttpResponse.json(
+ resource({
+ ...portfolio,
+ positions:
+ accountId === "all"
+ ? ["account-1", "account-2"].map((id) => ({
+ ...position,
+ account: { id, name: id },
+ }))
+ : [position],
+ }),
+ ),
),
http.get(`*/api/v1/${collection}/:uid`, () => {
requests();
- return HttpResponse.json({
- instrumentUid: uid,
- slug,
- name: "Название из карточки инструмента",
- ticker: "OTHER-TICKER",
- isin: "OTHER-ISIN",
- logoName: "other.png",
- });
+ return HttpResponse.json(
+ resource({
+ instrumentUid: uid,
+ slug,
+ name: "Название из карточки инструмента",
+ ticker: "OTHER-TICKER",
+ isin: "OTHER-ISIN",
+ logoName: "other.png",
+ }),
+ );
}),
);
configureTestAuth();
@@ -204,22 +209,24 @@ describe("AccountPage states", () => {
const requests = vi.fn();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
- HttpResponse.json({
- ...portfolio,
- positions: [
- {
- ...portfolio.positions[0],
- instrumentUid: uid,
- ticker: "SBERP",
- },
- ],
- }),
+ HttpResponse.json(
+ resource({
+ ...portfolio,
+ positions: [
+ {
+ ...portfolio.positions[0],
+ instrumentUid: uid,
+ ticker: "SBERP",
+ },
+ ],
+ }),
+ ),
),
http.get("*/api/v1/stocks/:uid", () => {
requests();
return scenario === "unavailable"
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
- : HttpResponse.json({ instrumentUid: uid, slug: uid });
+ : HttpResponse.json(resource({ instrumentUid: uid, slug: uid }));
}),
);
renderAccountRoute(createTestQueryClient());
@@ -234,7 +241,7 @@ describe("AccountPage states", () => {
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
- HttpResponse.json({ ...portfolio, structure: undefined }),
+ HttpResponse.json(resource({ ...portfolio, structure: undefined })),
),
);
renderAccountRoute(createTestQueryClient());
@@ -322,11 +329,13 @@ describe("AccountPage states", () => {
async (hasPositions) => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
- HttpResponse.json({
- ...portfolio,
- totalsByCurrency: [],
- positions: hasPositions ? portfolio.positions : [],
- }),
+ HttpResponse.json(
+ resource({
+ ...portfolio,
+ totalsByCurrency: [],
+ positions: hasPositions ? portfolio.positions : [],
+ }),
+ ),
),
);
renderAccountRoute(createTestQueryClient());
@@ -358,7 +367,7 @@ describe("AccountPage states", () => {
};
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
- HttpResponse.json(snapshot),
+ HttpResponse.json(resource(snapshot)),
),
);
const queryClient = createTestQueryClient();
@@ -380,7 +389,7 @@ describe("AccountPage states", () => {
).toEqual(totalsByCurrency.length ? ["100 ₽"] : []);
expect(
queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey),
- ).toEqual(snapshot);
+ ).toEqual(resource(snapshot));
},
);
@@ -887,7 +896,7 @@ describe("AccountPage states", () => {
expect(screen.getByText("Вторая")).toBeInTheDocument();
});
- it.each([503, 404])(
+ it.each([503])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio = {
@@ -907,9 +916,36 @@ describe("AccountPage states", () => {
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
+ http.post("*/api/v1/tbank-sync", () =>
+ HttpResponse.json(
+ {
+ id: "overview",
+ scope: { screen: "account", accountId: "account-1" },
+ now: "2026-09-25T00:00:00Z",
+ },
+ { status: 202 },
+ ),
+ ),
+ http.get("*/api/v1/tbank-sync/:id", () =>
+ HttpResponse.json({
+ id: "overview",
+ scope: { screen: "account", accountId: "account-1" },
+ now: "2026-09-25T00:00:00Z",
+ state: "succeeded",
+ errorCode: null,
+ progress: { total: 1, completed: 1, discovering: false },
+ resources: [
+ resource(null, { resourceKey: "portfolio", stateVersion: "2" })
+ .meta,
+ ],
+ }),
+ ),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
- if (requestCount === 1) return HttpResponse.json(initialPortfolio);
+ if (requestCount === 1)
+ return HttpResponse.json(
+ resource(initialPortfolio, { resourceKey: "portfolio" }),
+ );
if (requestCount === 3)
return new Promise((resolve) => {
resolveRetry = resolve;
@@ -961,14 +997,19 @@ describe("AccountPage states", () => {
snapshotText,
);
resolveRetry?.(
- HttpResponse.json({
- ...portfolio,
- isStale: false,
- lastSuccessfulAt: "2026-08-14T11:30:00Z",
- totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
- structure: { currencies: [], unvaluedCategories: [] },
- cashBalances: [{ currency: "EUR", value: "2.50" }],
- }),
+ HttpResponse.json(
+ resource(
+ {
+ ...portfolio,
+ isStale: false,
+ lastSuccessfulAt: "2026-08-14T11:30:00Z",
+ totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
+ structure: { currencies: [], unvaluedCategories: [] },
+ cashBalances: [{ currency: "EUR", value: "2.50" }],
+ },
+ { resourceKey: "portfolio", stateVersion: "2" },
+ ),
+ ),
);
await waitFor(() =>
expect(
@@ -993,7 +1034,7 @@ describe("AccountPage states", () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
- if (requestCount === 1) return HttpResponse.json(portfolio);
+ if (requestCount === 1) return HttpResponse.json(resource(portfolio));
return new Promise((resolve) => {
resolveRefresh = resolve;
});
@@ -1020,6 +1061,6 @@ describe("AccountPage states", () => {
screen.getByRole("status", { name: "Обновляем портфель" }),
).toBeInTheDocument();
- resolveRefresh?.(HttpResponse.json(portfolio));
+ resolveRefresh?.(HttpResponse.json(resource(portfolio)));
});
});
diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx
similarity index 91%
rename from src/pages/account/ui/AccountPage.tsx
rename to src/pages/account/ui/AccountPage/AccountPage.tsx
index 5e7023f..0b3e294 100644
--- a/src/pages/account/ui/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage/AccountPage.tsx
@@ -3,7 +3,7 @@ import { useAccountPageViewModel } from "./AccountPage.vm";
import { AccountPageEmpty } from "./components/AccountPageEmpty.view";
import { AccountPageError } from "./components/AccountPageError.view";
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
-import { AccountPageLoading } from "./components/AccountPageLoading.view";
+import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view";
type AccountPageProps = { accountId?: string };
@@ -17,6 +17,7 @@ const AccountPage: FC = (props) => {
account={viewModel.account}
accountId={viewModel.accountId}
refreshing={viewModel.refreshing}
+ sourceError={viewModel.sourceError}
refreshError={viewModel.refreshError}
retry={viewModel.retry}
recentOperations={viewModel.recentOperations}
@@ -28,9 +29,11 @@ const AccountPage: FC = (props) => {
- HttpResponse.json(portfolio),
+ HttpResponse.json(resource(portfolio)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
- HttpResponse.json({
- accountId: "account-1",
- items: [
- {
- id: "operation-1",
- occurredAt: "2026-08-13T10:00:00Z",
- type: "Buy",
- category: "trade",
- description: "Покупка акции",
- figi: "FIGI-STOCK",
- instrumentUid: null,
- ticker: "STK",
- instrumentType: "share",
- payment: { currency: "RUB", value: "-100" },
- quantity: "1",
- price: null,
- accruedInterest: null,
- commission: null,
- },
- ],
- nextCursor: null,
- hasNext: false,
- observedAt: "2026-08-13T10:00:00Z",
- }),
+ HttpResponse.json(
+ resource({
+ accountId: "account-1",
+ items: [
+ {
+ id: "operation-1",
+ occurredAt: "2026-08-13T10:00:00Z",
+ type: "Buy",
+ category: "trade",
+ description: "Покупка акции",
+ figi: "FIGI-STOCK",
+ instrumentUid: null,
+ ticker: "STK",
+ instrumentType: "share",
+ payment: { currency: "RUB", value: "-100" },
+ quantity: "1",
+ price: null,
+ accruedInterest: null,
+ commission: null,
+ },
+ ],
+ nextCursor: null,
+ hasNext: false,
+ observedAt: "2026-08-13T10:00:00Z",
+ }),
+ ),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
- HttpResponse.json({
- accountId: "account-1",
- calculatedAt: "2026-08-13T10:00:00Z",
- events: Array.from({ length: 6 }, (_, index) => ({
- type: "coupon",
- date: `2026-09-${String(index + 1).padStart(2, "0")}`,
- dateKind: "event",
- figi: `FIGI-EVENT-${index}`,
- instrumentUid: null,
- ticker: `BND${index}`,
- displayName: `Купон ${index}`,
- projectedAmount: { currency: "RUB", value: String(index + 1) },
- })),
- }),
+ HttpResponse.json(
+ resource({
+ accountId: "account-1",
+ calculatedAt: "2026-08-13T10:00:00Z",
+ events: Array.from({ length: 6 }, (_, index) => ({
+ type: "coupon",
+ date: `2026-09-${String(index + 1).padStart(2, "0")}`,
+ dateKind: "event",
+ figi: `FIGI-EVENT-${index}`,
+ instrumentUid: null,
+ ticker: `BND${index}`,
+ displayName: `Купон ${index}`,
+ projectedAmount: { currency: "RUB", value: String(index + 1) },
+ })),
+ }),
+ ),
),
);
}
@@ -281,7 +286,7 @@ describe("useAccountPageViewModel", () => {
configureTestAuth();
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
- HttpResponse.json(portfolio),
+ HttpResponse.json(resource(portfolio)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
@@ -304,3 +309,111 @@ describe("useAccountPageViewModel", () => {
);
});
});
+
+it("keeps overview sections independent when their source data is incomplete", async () => {
+ configureTestAuth();
+ mockLoadedAccountPage();
+ server.use(
+ http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
+ HttpResponse.json(resource(portfolio, { availability: "partial" })),
+ ),
+ http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
+ HttpResponse.json(
+ resource(
+ { items: [], hasNext: false, nextCursor: null },
+ { availability: "partial" },
+ ),
+ ),
+ ),
+ http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
+ HttpResponse.json(
+ resource(
+ { events: [], incomeTotalsByCurrency: [] },
+ { availability: "partial" },
+ ),
+ ),
+ ),
+ );
+ const { result } = renderHook(() => useAccountPageViewModel(), {
+ wrapper: createWrapper(),
+ });
+ await waitFor(() => expect(result.current.state).toBe("loaded"));
+ await waitFor(() =>
+ expect(result.current).toMatchObject({
+ partial: true,
+ recentOperations: { state: "partial", items: [] },
+ recentEvents: { state: "partial", items: [] },
+ }),
+ );
+});
+
+it("refreshes every overview section through one account descriptor and observational reads", async () => {
+ configureTestAuth();
+ const sections: Record = {
+ portfolio,
+ operations: { items: [], hasNext: false, nextCursor: null },
+ events: { events: [], incomeTotalsByCurrency: [] },
+ "income-summary": { totalsByCurrency: [] },
+ };
+ const 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 }),
+ );
+ },
+ ),
+ ),
+ http.post("*/api/v1/tbank-sync", async ({ request }) => {
+ scopes.push(await request.json());
+ version = "2";
+ return HttpResponse.json(
+ {
+ id: "overview",
+ scope: { screen: "account", accountId: "account-1" },
+ now: "2026-09-25T00:00:00Z",
+ },
+ { status: 202 },
+ );
+ }),
+ http.get("*/api/v1/tbank-sync/:id", () =>
+ HttpResponse.json({
+ id: "overview",
+ scope: null,
+ now: "2026-09-25T00:00:00Z",
+ state: "succeeded",
+ errorCode: null,
+ progress: { total: 4, completed: 4, discovering: false },
+ resources: Object.keys(sections).map(
+ (resourceKey) =>
+ resource(null, { resourceKey, stateVersion: version }).meta,
+ ),
+ }),
+ ),
+ );
+ const { result } = renderHook(() => useAccountPageViewModel(), {
+ wrapper: createWrapper(),
+ });
+ await waitFor(() =>
+ expect(result.current).toMatchObject({
+ state: "loaded",
+ recentOperations: { state: "empty" },
+ recentEvents: { state: "empty" },
+ incomeSummary: { state: "empty" },
+ }),
+ );
+ 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([{ screen: "account", accountId: "account-1" }]);
+});
diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
similarity index 74%
rename from src/pages/account/ui/AccountPage.vm.ts
rename to src/pages/account/ui/AccountPage/AccountPage.vm.ts
index eb221f8..538aa76 100644
--- a/src/pages/account/ui/AccountPage.vm.ts
+++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts
@@ -1,10 +1,9 @@
-import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router";
import type { BrokerageOperation } from "@/entities/broker-operation";
import { useBrokerageOperations } from "@/entities/broker-operation";
import {
type BrokerageAccount,
- brokerageAccountsQueryKey,
+ useBrokerageAccounts,
} from "@/entities/brokerage-account";
import {
type BrokerageEventsPreviewState,
@@ -14,6 +13,7 @@ import { type IncomeSummary, useIncomeSummary } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { formatShare, usePortfolio } from "@/entities/portfolio";
import { useStocks } from "@/entities/stock";
+import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
@@ -46,19 +46,31 @@ type AccountPageRecentOperations =
to: string;
}
| {
- state: "loaded";
+ state: "loaded" | "partial";
to: string;
items: BrokerageOperation[];
+ lastSuccessfulAt?: string | null;
+ stale?: boolean;
+ refreshError?: boolean;
};
type AccountPageRecentEvents = BrokerageEventsPreviewState;
type AccountPageIncomeSummary =
- | { state: "loading" | "empty"; to: string }
+ | { state: "loading" | "partial" | "empty"; to: string }
| { state: "error"; to: string; retry: () => void }
- | { state: "loaded"; to: string; summary: IncomeSummary };
+ | {
+ state: "loaded";
+ to: string;
+ summary: IncomeSummary;
+ lastSuccessfulAt?: string | null;
+ stale?: boolean;
+ refreshError?: boolean;
+ };
type AccountPageContent = {
+ sourceError?: boolean;
+ partial?: boolean;
account?: BrokerageAccount;
accountId?: string;
refreshing: boolean;
@@ -102,27 +114,39 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
});
const events = useBrokerageEvents(accountId);
const income = useIncomeSummary(accountId);
- const account = useQueryClient()
- .getQueryData(brokerageAccountsQueryKey)
- ?.find((item) => item.id === accountId);
+ const accounts = useBrokerageAccounts();
+ const sync = useScreenSync({ screen: "account", accountId }, [
+ accounts,
+ portfolio,
+ operations,
+ events,
+ income,
+ ]);
+ const account = accounts.data?.find((item) => item.id === accountId);
const recentOperations = toRecentOperations(accountId, operations);
const recentEvents = toRecentEvents(accountId, events);
const incomeSummary = toIncomeSummary(accountId, income);
if (!portfolio.data) {
- if (portfolio.isError)
+ if (portfolio.isError || portfolio.meta?.sync.state === "failed")
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
- retry: () => portfolio.refetch(),
+ retry: sync.refresh,
};
return { state: "loading" as const };
}
const refresh = {
- refreshing: portfolio.isFetching,
- refreshError: portfolio.isError,
- retry: () => portfolio.refetch(),
+ partial: portfolio.meta?.availability === "partial",
+ refreshing: portfolio.isFetching || sync.refreshing,
+ sourceError: sync.sourceError,
+ refreshError: portfolio.isError || sync.transportError || sync.sourceError,
+ retry: sync.refresh,
};
- if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
+ if (
+ portfolio.meta?.availability === "complete" &&
+ !portfolio.data.positions.length &&
+ !portfolio.data.cashBalances.length
+ )
return {
state: "empty" as const,
account,
@@ -153,11 +177,20 @@ function toIncomeSummary(
income: ReturnType,
): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`;
- if (income.isLoading) return { state: "loading", to };
- if (income.isError)
+ if (!income.data && !income.isError && income.meta?.sync.state !== "failed")
+ return { state: "loading", to };
+ if (income.meta?.availability === "partial") return { state: "partial", to };
+ if (!income.data && (income.isError || income.meta?.sync.state === "failed"))
return { state: "error", to, retry: () => income.refetch() };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
- return { state: "loaded", to, summary: income.data };
+ return {
+ state: "loaded",
+ to,
+ summary: income.data,
+ lastSuccessfulAt: income.meta?.lastSuccessfulAt,
+ stale: income.meta?.freshness === "stale",
+ refreshError: income.isError || income.meta?.sync.state === "failed",
+ };
}
function toRecentOperations(
@@ -165,10 +198,29 @@ function toRecentOperations(
operations: ReturnType,
): AccountPageRecentOperations {
const to = `/accounts/${accountId}/operations`;
- if (operations.isLoading) return { state: "loading", to };
- if (operations.isError) return { state: "error", to };
+ if (
+ !operations.data &&
+ !operations.isError &&
+ operations.meta?.sync.state !== "failed"
+ )
+ return { state: "loading", to };
+ if (operations.meta?.availability === "partial")
+ return { state: "partial", to, items: operations.data?.items ?? [] };
+ if (
+ !operations.data &&
+ (operations.isError || operations.meta?.sync.state === "failed")
+ )
+ return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to };
- return { state: "loaded", to, items: operations.data.items };
+ return {
+ state: "loaded",
+ to,
+ items: operations.data.items,
+ lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
+ stale: operations.meta?.freshness === "stale",
+ refreshError:
+ operations.isError || operations.meta?.sync.state === "failed",
+ };
}
function toRecentEvents(
@@ -176,11 +228,26 @@ function toRecentEvents(
events: ReturnType,
): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`;
- if (events.isLoading) return { state: "loading", to };
- if (events.isError) return { state: "error", to };
+ if (!events.data && !events.isError && events.meta?.sync.state !== "failed")
+ return { state: "loading", to };
+ if (events.meta?.availability === "partial")
+ return {
+ state: "partial",
+ to,
+ items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
+ };
+ if (!events.data && (events.isError || events.meta?.sync.state === "failed"))
+ return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (!items.length) return { state: "empty", to };
- return { state: "loaded", to, items };
+ return {
+ state: "loaded",
+ to,
+ items,
+ lastSuccessfulAt: events.meta?.lastSuccessfulAt,
+ stale: events.meta?.freshness === "stale",
+ refreshError: events.isError || events.meta?.sync.state === "failed",
+ };
}
function toAccountPagePositionSections(
diff --git a/src/pages/account/ui/components/AccountHeading.view.tsx b/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx
similarity index 100%
rename from src/pages/account/ui/components/AccountHeading.view.tsx
rename to src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx
diff --git a/src/pages/account/ui/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
similarity index 85%
rename from src/pages/account/ui/components/AccountPageEmpty.view.tsx
rename to src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
index 8e8ca97..6c6b076 100644
--- a/src/pages/account/ui/components/AccountPageEmpty.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx
@@ -1,9 +1,9 @@
import { Stack } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageEventsPreview } from "@/entities/brokerage-event";
-import { StateView } from "@/shared/ui";
import type {
AccountPageIncomeSummary,
AccountPageRecentEvents,
@@ -11,14 +11,15 @@ import type {
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
-import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
-import { PortfolioStructure } from "./PortfolioStructure";
+import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
+import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
account?: BrokerageAccount;
accountId?: string;
refreshing?: boolean;
+ sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -33,6 +34,7 @@ const AccountPageEmpty: FC = (props) => {
diff --git a/src/pages/account/ui/components/AccountPageError.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx
similarity index 96%
rename from src/pages/account/ui/components/AccountPageError.view.tsx
rename to src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx
index f10b739..4d85c56 100644
--- a/src/pages/account/ui/components/AccountPageError.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx
@@ -1,8 +1,8 @@
import { Button } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { StateView } from "@/shared/ui";
type AccountPageErrorProps = { code?: string; retry: () => void };
diff --git a/src/pages/account/ui/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
similarity index 86%
rename from src/pages/account/ui/components/AccountPageLoaded.view.tsx
rename to src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
index 5c0a6c6..9bce2ff 100644
--- a/src/pages/account/ui/components/AccountPageLoaded.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx
@@ -13,19 +13,21 @@ import type {
AccountPageRecentOperations,
} from "../AccountPage.vm";
import { AccountHeading } from "./AccountHeading.view";
-import { CurrencyCards } from "./CurrencyCards.view";
+import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
-import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
-import { PortfolioStructure } from "./PortfolioStructure";
+import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view";
+import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure";
import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageLoadedProps = {
account?: BrokerageAccount;
accountId?: string;
+ partial?: boolean;
portfolio: Portfolio;
positionSections: AccountPagePositionSection[];
refreshing?: boolean;
+ sourceError?: boolean;
refreshError?: boolean;
retry?: () => void;
recentOperations: AccountPageRecentOperations;
@@ -40,6 +42,7 @@ const AccountPageLoaded: FC = (props) => {
@@ -56,11 +59,17 @@ const AccountPageLoaded: FC = (props) => {
},
)}
-
-
+ {props.partial ? (
+ {t("investment.partial")}
+ ) : (
+
+ )}
+ {!props.partial ? (
+
+ ) : null}
{
const { t } = useTranslation();
diff --git a/src/pages/account/ui/components/CurrencyCards.test.tsx b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx
similarity index 100%
rename from src/pages/account/ui/components/CurrencyCards.test.tsx
rename to src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx
diff --git a/src/pages/account/ui/components/CurrencyCards.view.tsx b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx
similarity index 97%
rename from src/pages/account/ui/components/CurrencyCards.view.tsx
rename to src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx
index 9c3c2b8..63f2395 100644
--- a/src/pages/account/ui/components/CurrencyCards.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatDecimal } from "@/shared/lib";
-import classes from "../AccountPage.module.css";
+import classes from "../../AccountPage.module.css";
type CurrencyCardsProps = {
amounts: Portfolio["totalsByCurrency"];
diff --git a/src/pages/account/ui/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
similarity index 62%
rename from src/pages/account/ui/components/IncomeSummary.view.tsx
rename to src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
index 6b86d66..1ca3cd2 100644
--- a/src/pages/account/ui/components/IncomeSummary.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx
@@ -1,21 +1,37 @@
import { Button, Paper, Stack, Text } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { IncomeSummary as IncomeSummaryData } from "@/entities/income";
-import { formatMoney } from "@/shared/lib";
-import { StateView } from "@/shared/ui";
+import { formatMoney, formatSnapshotTime } from "@/shared/lib";
type IncomeSummaryProps =
| { state: "loading"; to: string }
+ | { state: "partial"; to: string }
| { state: "error"; to: string; retry: () => void }
| { state: "empty"; to: string }
- | { state: "loaded"; to: string; summary: IncomeSummaryData };
+ | {
+ state: "loaded";
+ to: string;
+ summary: IncomeSummaryData;
+ lastSuccessfulAt?: string | null;
+ stale?: boolean;
+ refreshError?: boolean;
+ };
const IncomeSummary: FC = (props) => {
const { t } = useTranslation();
- if (props.state === "loading")
- return {t("income.summaryLoading")};
+ if (props.state === "loading" || props.state === "partial")
+ return (
+
+ {t(
+ props.state === "partial"
+ ? "investment.partial"
+ : "income.summaryLoading",
+ )}
+
+ );
if (props.state === "error")
return (
= (props) => {
);
return (
+ {props.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(props.lastSuccessfulAt),
+ })}
+
+ ) : null}
+ {props.stale ? {t("investment.stale")} : null}
+ {props.refreshError ? (
+ {t("income.refreshError")}
+ ) : null}
{props.summary.totalsByCurrency.map((total) => (
{formatMoney(total.total)}
diff --git a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx
similarity index 87%
rename from src/pages/account/ui/components/PortfolioRefreshState.stories.tsx
rename to src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx
index 5c7358b..28f7d9a 100644
--- a/src/pages/account/ui/components/PortfolioRefreshState.stories.tsx
+++ b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx
@@ -1,7 +1,7 @@
import { Stack } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
-import { CurrencyCards } from "./CurrencyCards.view";
+import { CurrencyCards } from "../CurrencyCards/CurrencyCards.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
const meta = {
@@ -47,3 +47,8 @@ export const RefreshError: Story = {
name: "Ошибка обновления с retry",
args: { refreshError: true },
};
+
+export const SourceFailure: Story = {
+ name: "Ошибка синхронизации сохраняет суммы",
+ args: { refreshError: true, sourceError: true },
+};
diff --git a/src/pages/account/ui/components/PortfolioRefreshState.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx
similarity index 66%
rename from src/pages/account/ui/components/PortfolioRefreshState.view.tsx
rename to src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx
index 16a8c5b..514dd6f 100644
--- a/src/pages/account/ui/components/PortfolioRefreshState.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx
@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
type PortfolioRefreshStateProps = {
+ sourceError?: boolean;
refreshing: boolean;
refreshError: boolean;
retry?: () => void;
@@ -11,10 +12,17 @@ type PortfolioRefreshStateProps = {
const PortfolioRefreshState: FC = (props) => {
const { refreshing, refreshError, retry } = props;
const { t } = useTranslation();
- if (!refreshing && !refreshError) return null;
return (
<>
+
+ {t(refreshError ? "accounts.retry" : "investment.refresh")}
+
{refreshing ? (
@@ -24,12 +32,15 @@ const PortfolioRefreshState: FC = (props) => {
) : null}
{refreshError ? (
-
+
- {t("portfolio.refreshError")}
-
- {t("accounts.retry")}
-
+
+ {t(
+ props.sourceError
+ ? "investment.syncFailed"
+ : "portfolio.refreshError",
+ )}
+
) : null}
diff --git a/src/pages/account/ui/components/PortfolioStructure.module.css b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.module.css
similarity index 100%
rename from src/pages/account/ui/components/PortfolioStructure.module.css
rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.module.css
diff --git a/src/pages/account/ui/components/PortfolioStructure.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx
similarity index 100%
rename from src/pages/account/ui/components/PortfolioStructure.stories.tsx
rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx
diff --git a/src/pages/account/ui/components/PortfolioStructure.test.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx
similarity index 100%
rename from src/pages/account/ui/components/PortfolioStructure.test.tsx
rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx
diff --git a/src/pages/account/ui/components/PortfolioStructure.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx
similarity index 100%
rename from src/pages/account/ui/components/PortfolioStructure.tsx
rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx
diff --git a/src/pages/account/ui/components/PortfolioStructure.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx
similarity index 100%
rename from src/pages/account/ui/components/PortfolioStructure.view.tsx
rename to src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx
diff --git a/src/pages/account/ui/components/PositionCard.stories.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx
similarity index 98%
rename from src/pages/account/ui/components/PositionCard.stories.tsx
rename to src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx
index a00c986..c241eae 100644
--- a/src/pages/account/ui/components/PositionCard.stories.tsx
+++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
-import type { AccountPagePosition } from "../AccountPage.vm";
+import type { AccountPagePosition } from "../../AccountPage.vm";
import { PositionCard } from "./PositionCard.view";
const syntheticPosition = {
diff --git a/src/pages/account/ui/components/PositionCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx
similarity index 96%
rename from src/pages/account/ui/components/PositionCard.view.tsx
rename to src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx
index 7505478..5984ce6 100644
--- a/src/pages/account/ui/components/PositionCard.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx
@@ -3,12 +3,12 @@ import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatMoney } from "@/shared/lib";
-import classes from "../AccountPage.module.css";
+import classes from "../../AccountPage.module.css";
import type {
AccountPagePosition,
AccountPagePositionSection,
-} from "../AccountPage.vm";
-import { PositionLogo } from "./PositionLogo.view";
+} from "../../AccountPage.vm";
+import { PositionLogo } from "../PositionLogo.view";
type PositionCardProps = {
kind: AccountPagePositionSection["kind"];
diff --git a/src/pages/account/ui/components/PositionGroupCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx
similarity index 100%
rename from src/pages/account/ui/components/PositionGroupCard.view.tsx
rename to src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx
diff --git a/src/pages/account/ui/components/PositionLogo.view.tsx b/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx
similarity index 100%
rename from src/pages/account/ui/components/PositionLogo.view.tsx
rename to src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx
diff --git a/src/pages/account/ui/components/PositionSection.view.tsx b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx
similarity index 92%
rename from src/pages/account/ui/components/PositionSection.view.tsx
rename to src/pages/account/ui/AccountPage/components/PositionSection.view.tsx
index 6e2667b..2639826 100644
--- a/src/pages/account/ui/components/PositionSection.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx
@@ -1,7 +1,7 @@
import { SimpleGrid } from "@mantine/core";
import type { FC } from "react";
import type { AccountPagePositionSection } from "../AccountPage.vm";
-import { PositionCard } from "./PositionCard.view";
+import { PositionCard } from "./PositionCard/PositionCard.view";
import { PositionGroupCard } from "./PositionGroupCard.view";
type PositionSectionProps = {
diff --git a/src/pages/account/ui/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
similarity index 64%
rename from src/pages/account/ui/components/RecentOperations.view.tsx
rename to src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
index 5829887..be267dc 100644
--- a/src/pages/account/ui/components/RecentOperations.view.tsx
+++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx
@@ -1,9 +1,10 @@
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { LedgerEntryRow } from "@/entities/broker-operation";
-import { LoadingSkeleton } from "@/shared/ui";
+import { formatSnapshotTime } from "@/shared/lib";
import type { AccountPageRecentOperations } from "../AccountPage.vm";
type RecentOperationsProps = {
@@ -16,6 +17,23 @@ const RecentOperations: FC = (props) => {
return (
+ {operations.state === "loaded" ? (
+ <>
+ {operations.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(operations.lastSuccessfulAt),
+ })}
+
+ ) : null}
+ {operations.stale ? (
+ {t("investment.stale")}
+ ) : null}
+ {operations.refreshError ? (
+ {t("accounts.refreshError")}
+ ) : null}
+ >
+ ) : null}
{operations.state === "loading" ? (
{[0, 1, 2].map((index) => (
@@ -29,7 +47,10 @@ const RecentOperations: FC = (props) => {
{operations.state === "empty" ? (
{t("ledger.recentEmpty")}
) : null}
- {operations.state === "loaded"
+ {operations.state === "partial" ? (
+ {t("investment.partial")}
+ ) : null}
+ {operations.state === "loaded" || operations.state === "partial"
? operations.items.map((operation) => (
accounts.refetch(),
- };
- }
- if (!accounts.data?.length)
- return {
- state: "empty" as const,
- refreshing: accounts.isFetching,
- refreshError: accounts.isError,
- retry: () => accounts.refetch(),
- };
- return {
- state: "loaded" as const,
- accounts: brokerageAccountCards(accounts.data),
- refreshing: accounts.isFetching,
- refreshError: accounts.isError,
- retry: () => accounts.refetch(),
- };
-}
-
-export { useAccountsPageViewModel };
diff --git a/src/pages/accounts/ui/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
similarity index 79%
rename from src/pages/accounts/ui/AccountsPage.test.tsx
rename to src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
index 849de0c..3d27fa3 100644
--- a/src/pages/accounts/ui/AccountsPage.test.tsx
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AccountsPage } from "./AccountsPage";
import { AccountsPageEmpty } from "./components/AccountsPageEmpty.view";
@@ -80,7 +81,7 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
- if (requestCount === 1) return HttpResponse.json([account]);
+ if (requestCount === 1) return HttpResponse.json(resource([account]));
return new Promise((resolve) => {
resolveRefresh = resolve;
});
@@ -100,21 +101,31 @@ describe("AccountsPage", () => {
screen.getByRole("status", { name: "Обновляем данные" }),
).toBeInTheDocument();
- resolveRefresh?.(HttpResponse.json([account]));
+ resolveRefresh?.(HttpResponse.json(resource([account])));
});
it("keeps the cached account list and offers retry after a refresh error", async () => {
+ const requests: unknown[] = [];
+ server.use(
+ http.post("*/api/v1/tbank-sync", async ({ request }) => {
+ requests.push(await request.json());
+ return HttpResponse.json(
+ { code: "TBANK_UNAVAILABLE" },
+ { status: 503 },
+ );
+ }),
+ );
let requestCount = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
- if (requestCount === 1) return HttpResponse.json([account]);
+ if (requestCount === 1) return HttpResponse.json(resource([account]));
if (requestCount === 2)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
- return HttpResponse.json([account]);
+ return HttpResponse.json(resource([account]));
}),
);
const queryClient = createTestQueryClient();
@@ -132,7 +143,8 @@ describe("AccountsPage", () => {
expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click();
- await waitFor(() => expect(requestCount).toBe(3));
+ await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
+ expect(requestCount).toBe(2);
});
it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -140,7 +152,7 @@ describe("AccountsPage", () => {
server.use(
http.get("*/api/v1/brokerage-accounts", () => {
requestCount += 1;
- if (requestCount === 1) return HttpResponse.json([]);
+ if (requestCount === 1) return HttpResponse.json(resource([]));
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
@@ -163,3 +175,22 @@ describe("AccountsPage", () => {
expect(screen.getByText("Нет инвестиционных счетов")).toBeInTheDocument();
});
});
+
+it("keeps unknown membership loading instead of declaring no accounts", async () => {
+ server.use(
+ http.get("*/api/v1/brokerage-accounts", () =>
+ HttpResponse.json(resource(null)),
+ ),
+ );
+ const client = createTestQueryClient();
+ renderPage(client);
+ await waitFor(() =>
+ expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
+ data: null,
+ }),
+ );
+ expect(
+ screen.queryByText("Нет инвестиционных счетов"),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("status")).toBeVisible();
+});
diff --git a/src/pages/accounts/ui/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
similarity index 85%
rename from src/pages/accounts/ui/AccountsPage.tsx
rename to src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
index 29e2294..224e80e 100644
--- a/src/pages/accounts/ui/AccountsPage.tsx
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx
@@ -12,7 +12,9 @@ const AccountsPage: FC = () => {
if (viewModel.state === "empty")
return (
@@ -20,7 +22,9 @@ const AccountsPage: FC = () => {
return (
diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts
new file mode 100644
index 0000000..a6629a7
--- /dev/null
+++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts
@@ -0,0 +1,44 @@
+import {
+ brokerageAccountCards,
+ useBrokerageAccounts,
+} from "@/entities/brokerage-account";
+import { useScreenSync } from "@/features/tbank-connection";
+import { apiErrorCode } from "@/shared/api";
+
+function useAccountsPageViewModel() {
+ const accounts = useBrokerageAccounts();
+ const sync = useScreenSync({ screen: "accounts" }, [accounts]);
+ if (
+ !accounts.data ||
+ (accounts.meta?.availability === "partial" && !accounts.data.length)
+ ) {
+ if (!accounts.isError && accounts.meta?.sync.state !== "failed")
+ return { state: "loading" as const };
+ if (accounts.isError || accounts.meta?.sync.state === "failed")
+ return {
+ state: "error" as const,
+ code: apiErrorCode(accounts.error),
+ retry: sync.refresh,
+ };
+ }
+ if (!accounts.data?.length)
+ return {
+ state: "empty" as const,
+ lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
+ refreshing: accounts.isFetching || sync.refreshing,
+ sourceError: sync.sourceError,
+ refreshError: accounts.isError || sync.transportError || sync.sourceError,
+ retry: sync.refresh,
+ };
+ return {
+ state: "loaded" as const,
+ accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies),
+ lastSuccessfulAt: accounts.meta?.lastSuccessfulAt,
+ refreshing: accounts.isFetching || sync.refreshing,
+ sourceError: sync.sourceError,
+ refreshError: accounts.isError || sync.transportError || sync.sourceError,
+ retry: sync.refresh,
+ };
+}
+
+export { useAccountsPageViewModel };
diff --git a/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
similarity index 91%
rename from src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx
rename to src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
index c7e63b8..5e16dc4 100644
--- a/src/pages/accounts/ui/components/AccountsPageEmpty.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageEmpty.view.tsx
@@ -1,7 +1,7 @@
import { Stack } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { StateView } from "@/shared/ui";
import {
AccountsRefreshState,
type AccountsRefreshStateProps,
diff --git a/src/pages/accounts/ui/components/AccountsPageError.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
similarity index 96%
rename from src/pages/accounts/ui/components/AccountsPageError.view.tsx
rename to src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
index 46faf78..a808eec 100644
--- a/src/pages/accounts/ui/components/AccountsPageError.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageError.view.tsx
@@ -1,8 +1,8 @@
import { Button, Stack } from "@mantine/core";
+import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
-import { StateView } from "@/shared/ui";
type AccountsPageErrorProps = { code?: string; retry?: () => void };
diff --git a/src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
similarity index 100%
rename from src/pages/accounts/ui/components/AccountsPageLoaded.view.tsx
rename to src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx
diff --git a/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx
similarity index 89%
rename from src/pages/accounts/ui/components/AccountsPageLoading.view.tsx
rename to src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx
index 5248e56..029b466 100644
--- a/src/pages/accounts/ui/components/AccountsPageLoading.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx
@@ -1,7 +1,7 @@
import { Card, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
-import { LoadingSkeleton } from "@/shared/ui";
const AccountsPageLoading: FC = () => {
const { t } = useTranslation();
@@ -9,7 +9,7 @@ const AccountsPageLoading: FC = () => {
-
+
{[0, 1].map((index) => (
diff --git a/src/pages/accounts/ui/components/AccountsRefreshState.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx
similarity index 55%
rename from src/pages/accounts/ui/components/AccountsRefreshState.view.tsx
rename to src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx
index 422c7ce..a68f534 100644
--- a/src/pages/accounts/ui/components/AccountsRefreshState.view.tsx
+++ b/src/pages/accounts/ui/AccountsPage/components/AccountsRefreshState.view.tsx
@@ -1,8 +1,11 @@
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
+import { formatSnapshotTime } from "@/shared/lib";
type AccountsRefreshStateProps = {
+ lastSuccessfulAt?: string | null;
+ sourceError?: boolean;
refreshing?: boolean;
refreshError?: boolean;
retry?: () => void;
@@ -11,9 +14,24 @@ type AccountsRefreshStateProps = {
const AccountsRefreshState: FC = (props) => {
const { refreshing = false, refreshError = false, retry } = props;
const { t } = useTranslation();
- if (!refreshing && !refreshError) return null;
+
return (
<>
+ {props.lastSuccessfulAt ? (
+
+ {t("investment.sourceTime", {
+ date: formatSnapshotTime(props.lastSuccessfulAt),
+ })}
+
+ ) : null}
+
+ {t(refreshError ? "accounts.retry" : "investment.refresh")}
+
{refreshing ? (
@@ -23,12 +41,15 @@ const AccountsRefreshState: FC = (props) => {
) : null}
{refreshError ? (
-
+
- {t("accounts.refreshError")}
-
- {t("accounts.retry")}
-
+
+ {t(
+ props.sourceError
+ ? "investment.syncFailed"
+ : "accounts.refreshError",
+ )}
+
) : null}
diff --git a/src/pages/bond/index.ts b/src/pages/bond/index.ts
index 5b92960..3b67849 100644
--- a/src/pages/bond/index.ts
+++ b/src/pages/bond/index.ts
@@ -1 +1 @@
-export { BondPage } from "./ui/BondPage";
+export { BondPage } from "./ui/BondPage/BondPage";
diff --git a/src/pages/bond/ui/BondPage.module.css b/src/pages/bond/ui/BondPage/BondPage.module.css
similarity index 100%
rename from src/pages/bond/ui/BondPage.module.css
rename to src/pages/bond/ui/BondPage/BondPage.module.css
diff --git a/src/pages/bond/ui/BondPage.test.tsx b/src/pages/bond/ui/BondPage/BondPage.test.tsx
similarity index 69%
rename from src/pages/bond/ui/BondPage.test.tsx
rename to src/pages/bond/ui/BondPage/BondPage.test.tsx
index 7f5976d..2fb9480 100644
--- a/src/pages/bond/ui/BondPage.test.tsx
+++ b/src/pages/bond/ui/BondPage/BondPage.test.tsx
@@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { BondPage } from "./BondPage";
@@ -92,127 +93,145 @@ function setup(
http.get("*/api/v1/bonds/:uid", () =>
options.failBond
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
- : HttpResponse.json(resolved),
+ : HttpResponse.json(resource(resolved)),
),
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
requests.push(new URL(request.url));
return options.ambiguousTicker
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
- : HttpResponse.json(resolved);
+ : HttpResponse.json(resource(resolved));
}),
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
requests.push(new URL(request.url));
if (params.section === "metrics")
- return HttpResponse.json({
- creditRating:
- options.ratingState === "missing" ||
- options.ratingState === "unavailable"
- ? null
- : {
- agency: "NCR",
- value: options.ratingState === "withdrawn" ? null : "AAA.ru",
- status:
- options.ratingState === "withdrawn"
- ? "withdrawn"
- : "active",
- date: "2026-04-17",
- sourceUrl: "https://ratings.ru/ratings/issues/123/",
- observedAt: quote.observedAt,
- },
- coupon: money("32.88"),
- couponDate: "2026-10-27",
- couponType: "COUPON_TYPE_FLOATING",
- couponPeriodDays: 90,
- couponYieldPercent: "27.18",
- couponYieldPriceAt: quote.priceAt,
- effectiveYield: {
- value: "15.6262",
- pricePercent: "53.384",
- date: "2030-05-15",
- dateType: "MATDATE",
- tradeAt: quote.priceAt,
- },
- unavailable: options.ratingState === "unavailable" ? ["rating"] : [],
- observedAt: quote.observedAt,
- });
+ return HttpResponse.json(
+ resource({
+ creditRating:
+ options.ratingState === "missing" ||
+ options.ratingState === "unavailable"
+ ? null
+ : {
+ agency: "NCR",
+ value:
+ options.ratingState === "withdrawn" ? null : "AAA.ru",
+ status:
+ options.ratingState === "withdrawn"
+ ? "withdrawn"
+ : "active",
+ date: "2026-04-17",
+ sourceUrl: "https://ratings.ru/ratings/issues/123/",
+ observedAt: quote.observedAt,
+ },
+ coupon: money("32.88"),
+ couponDate: "2026-10-27",
+ couponType: "COUPON_TYPE_FLOATING",
+ couponPeriodDays: 90,
+ couponYieldPercent: "27.18",
+ couponYieldPriceAt: quote.priceAt,
+ effectiveYield: {
+ value: "15.6262",
+ pricePercent: "53.384",
+ date: "2030-05-15",
+ dateType: "MATDATE",
+ tradeAt: quote.priceAt,
+ },
+ unavailable:
+ options.ratingState === "unavailable" ? ["rating"] : [],
+ observedAt: quote.observedAt,
+ }),
+ );
if (params.section === "quote")
return failQuote
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
- : HttpResponse.json(quote);
+ : HttpResponse.json(resource(quote));
if (params.section === "position")
return HttpResponse.json(
- options.noPosition
- ? { ...position, quantity: "0", accounts: [] }
- : position,
+ resource(
+ options.noPosition
+ ? { ...position, quantity: "0", accounts: [] }
+ : position,
+ ),
);
if (params.section === "candles")
- return HttpResponse.json({
- candles: [
- { time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true },
- ],
- currency: null,
- historyFrom: null,
- observedAt: quote.observedAt,
- });
+ return HttpResponse.json(
+ resource({
+ candles: [
+ {
+ time: "2026-09-09T00:00:00Z",
+ close: "99.25",
+ isComplete: true,
+ },
+ ],
+ currency: null,
+ historyFrom: null,
+ observedAt: quote.observedAt,
+ }),
+ );
if (options.failCoupons)
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
- return HttpResponse.json({
- coupons: [
- {
- perBond: money("32.88"),
- recordDate: "2026-10-13",
- number: "1",
- couponType: "COUPON_TYPE_FLOATING",
- periodDays: 90,
- paymentDate: "2026-10-27",
- timing: "upcoming",
- estimatedAmount: options.noPosition ? null : money("328.8"),
- },
- {
- perBond: money("20"),
- recordDate: "2025-10-13",
- paymentDate: null,
- number: "2",
- couponType: null,
- periodDays: null,
- timing: "past",
- estimatedAmount: null,
- },
- ],
- from: "2021-09-10",
- to: "2027-09-10",
- observedAt: quote.observedAt,
- positionAt: null,
- estimateUnavailable: false,
- positionIsStale: false,
- });
+ return HttpResponse.json(
+ resource({
+ coupons: [
+ {
+ perBond: money("32.88"),
+ recordDate: "2026-10-13",
+ number: "1",
+ couponType: "COUPON_TYPE_FLOATING",
+ periodDays: 90,
+ paymentDate: "2026-10-27",
+ timing: "upcoming",
+ estimatedAmount: options.noPosition ? null : money("328.8"),
+ },
+ {
+ perBond: money("20"),
+ recordDate: "2025-10-13",
+ paymentDate: null,
+ number: "2",
+ couponType: null,
+ periodDays: null,
+ timing: "past",
+ estimatedAmount: null,
+ },
+ ],
+ from: "2021-09-10",
+ to: "2027-09-10",
+ observedAt: quote.observedAt,
+ positionAt: null,
+ estimateUnavailable: false,
+ positionIsStale: false,
+ }),
+ );
}),
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
- return HttpResponse.json({
- accountId: "all",
- observedAt: quote.observedAt,
- hasNext: !next,
- nextCursor: next ? null : "next",
- items: [
- {
- id: next ? "sold" : "dividend",
- occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
- type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
- category: next ? "trade" : "income",
- description: "",
- account: { id: "main", name: "Основной" },
- payment: money("200"),
- quantity: next ? "2" : null,
- price: next ? money("100") : null,
- },
- ],
- });
+ return HttpResponse.json(
+ resource({
+ accountId: "all",
+ observedAt: quote.observedAt,
+ hasNext: !next,
+ nextCursor: next ? null : "next",
+ items: [
+ {
+ id: next ? "sold" : "dividend",
+ occurredAt: next
+ ? "2022-01-02T10:00:00Z"
+ : "2025-01-02T10:00:00Z",
+ type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
+ category: next ? "trade" : "income",
+ description: "",
+ account: { id: "main", name: "Основной" },
+ payment: money("200"),
+ quantity: next ? "2" : null,
+ price: next ? money("100") : null,
+ },
+ ],
+ }),
+ );
}),
);
configureAuthClient({
@@ -300,9 +319,11 @@ describe("bond page", () => {
it("keeps market content and sold-position history when coupons fail", async () => {
setup({ noPosition: true, failCoupons: true });
await screen.findByText("98,125 %");
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "Не удалось загрузить этот раздел",
- );
+ expect(
+ (await screen.findByText(/Не удалось загрузить этот раздел/)).closest(
+ '[role="status"]',
+ ),
+ ).toHaveAttribute("aria-live", "polite");
expect(
screen.queryByRole("heading", { name: "Моя позиция" }),
).not.toBeInTheDocument();
@@ -311,14 +332,36 @@ describe("bond page", () => {
).toBeVisible();
});
it("preserves cached quote on refresh failure", async () => {
- const controls = setup();
+ 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 },
+ );
+ }),
+ );
+ const controls = setup({ path: `/bonds/${uid}?period=year&events=trades` });
await screen.findByText("98,125 %");
controls.failQuote();
- fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
- expect(await screen.findByRole("alert")).toHaveTextContent(
- "последние полученные",
- );
+ fireEvent.click(screen.getByRole("button", { name: "Обновить" }));
+ expect(
+ (await screen.findByText(/последние полученные/)).closest(
+ '[role="status"]',
+ ),
+ ).toHaveAttribute("aria-live", "polite");
expect(screen.getByText("98,125 %")).toBeVisible();
+ expect(scopes).toEqual([
+ {
+ screen: "bond",
+ instrumentUid: uid,
+ accountId: "all",
+ candlePeriod: "year",
+ categories: ["trade"],
+ limit: 20,
+ },
+ ]);
});
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
diff --git a/src/pages/bond/ui/BondPage.tsx b/src/pages/bond/ui/BondPage/BondPage.tsx
similarity index 96%
rename from src/pages/bond/ui/BondPage.tsx
rename to src/pages/bond/ui/BondPage/BondPage.tsx
index 63e0047..906dab0 100644
--- a/src/pages/bond/ui/BondPage.tsx
+++ b/src/pages/bond/ui/BondPage/BondPage.tsx
@@ -1,8 +1,8 @@
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link, Navigate } from "react-router";
-import { LoadingSkeleton } from "@/shared/ui";
import { BondPageView } from "./BondPage.view";
import { useBondPageVm } from "./BondPage.vm";
diff --git a/src/pages/bond/ui/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx
similarity index 96%
rename from src/pages/bond/ui/BondPage.view.tsx
rename to src/pages/bond/ui/BondPage/BondPage.view.tsx
index d194c8f..dc99d0d 100644
--- a/src/pages/bond/ui/BondPage.view.tsx
+++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx
@@ -12,6 +12,7 @@ import {
Text,
Title,
} from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -26,7 +27,6 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
-import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondPage.module.css";
import type { BondPageVm } from "./BondPage.vm";
@@ -55,13 +55,10 @@ const BondPageView: FC = (props) => {
{
- vm.bondState.retry();
- vm.quoteState.retry();
- vm.metricsState.retry();
- }}
+ onClick={vm.refresh}
+ disabled={vm.refreshing}
>
- {t("stock.refreshPrice")}
+ {t("investment.refresh")}
@@ -80,8 +77,8 @@ const BondPageView: FC = (props) => {
) : null}
- {vm.bondState.error || vm.quoteState.error ? (
-
+ {vm.refreshError || vm.bondState.error || vm.quoteState.error ? (
+
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
) : null}
@@ -322,7 +319,9 @@ const BondPageView: FC = (props) => {
{vm.coupons ? (
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
- {t("stock.estimateUnavailable")}
+
+ {t("stock.estimateUnavailable")}
+
) : null}
{vm.upcoming.length ? (
vm.upcoming.map((coupon) => (
diff --git a/src/pages/bond/ui/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts
similarity index 83%
rename from src/pages/bond/ui/BondPage.vm.ts
rename to src/pages/bond/ui/BondPage/BondPage.vm.ts
index 9819885..2ba6f75 100644
--- a/src/pages/bond/ui/BondPage.vm.ts
+++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts
@@ -16,6 +16,7 @@ import {
useBrokerageOperations,
} from "@/entities/broker-operation";
import { section } from "@/features/instrument-details";
+import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -58,6 +59,17 @@ function useBondPageVm() {
...firstPage,
cursor: cursors.at(-1),
});
+ const sync = useScreenSync(
+ {
+ screen: "bond",
+ instrumentUid: uid,
+ candlePeriod: period,
+ accountId: "all",
+ categories: categories[filter],
+ limit: 20,
+ },
+ [bond, quote, candles, position, metrics, coupons, operations],
+ );
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
next.set(key, value);
@@ -70,7 +82,7 @@ function useBondPageVm() {
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
- } else void operations.refetch();
+ } else void sync.refresh();
};
const chartData =
candles.data?.candles.map((candle) => ({
@@ -79,31 +91,34 @@ function useBondPageVm() {
price: Number(candle.close),
})) ?? [];
return {
+ refresh: sync.refresh,
+ refreshing: sync.refreshing,
+ refreshError: sync.transportError || sync.sourceError,
canonicalTo:
bond.data?.slug && bond.data.slug !== bondId
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
bond: bond.data,
- bondState: section(bond),
+ bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
- metricsState: section(metrics),
+ metricsState: section(metrics, sync.refresh),
quote: quote.data,
- quoteState: section(quote),
+ quoteState: section(quote, sync.refresh),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
- candlesState: section(candles),
+ candlesState: section(candles, sync.refresh),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine: null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
- positionState: section(position),
+ positionState: section(position, sync.refresh),
coupons: coupons.data,
- couponsState: section(coupons),
+ couponsState: section(coupons, sync.refresh),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
@@ -121,7 +136,10 @@ function useBondPageVm() {
),
) ?? [],
operations: operations.data,
- operationsState: { ...section(operations), retry: retryOperations },
+ operationsState: {
+ ...section(operations, sync.refresh),
+ retry: retryOperations,
+ },
eventRows:
operations.data?.items.map((item) => ({
...item,
diff --git a/src/pages/bonds/index.ts b/src/pages/bonds/index.ts
index 4328b67..3322d68 100644
--- a/src/pages/bonds/index.ts
+++ b/src/pages/bonds/index.ts
@@ -1 +1 @@
-export { BondsPage } from "./ui/BondsPage";
+export { BondsPage } from "./ui/BondsPage/BondsPage";
diff --git a/src/pages/bonds/ui/BondsPage.module.css b/src/pages/bonds/ui/BondsPage/BondsPage.module.css
similarity index 100%
rename from src/pages/bonds/ui/BondsPage.module.css
rename to src/pages/bonds/ui/BondsPage/BondsPage.module.css
diff --git a/src/pages/bonds/ui/BondsPage.test.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx
similarity index 75%
rename from src/pages/bonds/ui/BondsPage.test.tsx
rename to src/pages/bonds/ui/BondsPage/BondsPage.test.tsx
index 3242caf..6cfe1b9 100644
--- a/src/pages/bonds/ui/BondsPage.test.tsx
+++ b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx
@@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
+import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { BondsPage } from "./BondsPage";
@@ -15,24 +16,26 @@ describe("bond search", () => {
server.use(
http.get("*/api/v1/bonds", ({ request }) => {
query(new URL(request.url).searchParams.get("query"));
- return HttpResponse.json([
- {
- name: "ОФЗ",
- ticker: "SU26238RMFS4",
- instrumentUid: "unique-bond",
- slug: "SU26238RMFS4",
- logoName: "sber.png",
- classCode: "TQOB",
- isin: "RU0009029540",
- realExchange: "REAL_EXCHANGE_MOEX",
- shareType: "SHARE_TYPE_COMMON",
- apiTradeAvailable: true,
- pricePercent: "98.125",
- maturityDate: "2030-05-15",
- perpetual: false,
- priceAt: "2026-09-10T10:00:00Z",
- },
- ]);
+ return HttpResponse.json(
+ resource([
+ {
+ name: "ОФЗ",
+ ticker: "SU26238RMFS4",
+ instrumentUid: "unique-bond",
+ slug: "SU26238RMFS4",
+ logoName: "sber.png",
+ classCode: "TQOB",
+ isin: "RU0009029540",
+ realExchange: "REAL_EXCHANGE_MOEX",
+ shareType: "SHARE_TYPE_COMMON",
+ apiTradeAvailable: true,
+ pricePercent: "98.125",
+ maturityDate: "2030-05-15",
+ perpetual: false,
+ priceAt: "2026-09-10T10:00:00Z",
+ },
+ ]),
+ );
}),
);
configureAuthClient({
diff --git a/src/pages/bonds/ui/BondsPage.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.tsx
similarity index 100%
rename from src/pages/bonds/ui/BondsPage.tsx
rename to src/pages/bonds/ui/BondsPage/BondsPage.tsx
diff --git a/src/pages/bonds/ui/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx
similarity index 90%
rename from src/pages/bonds/ui/BondsPage.view.tsx
rename to src/pages/bonds/ui/BondsPage/BondsPage.view.tsx
index 66c42bf..43caa06 100644
--- a/src/pages/bonds/ui/BondsPage.view.tsx
+++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx
@@ -12,6 +12,7 @@ import {
TextInput,
Title,
} from "@mantine/core";
+import { LoadingSkeleton } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -21,7 +22,6 @@ import {
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
-import { LoadingSkeleton } from "@/shared/ui";
import classes from "./BondsPage.module.css";
import type { BondsPageVm } from "./BondsPage.vm";
@@ -37,6 +37,16 @@ const BondsPageView: FC = (props) => {
{t("bond.searchSubtitle")}
+
+ {t("investment.refresh")}
+
+ {vm.partial ? (
+ {t("investment.partial")}
+ ) : null}
= (props) => {
{t("stock.searchHint")}
) : null}
{vm.error ? (
-
+
{t(
@@ -166,7 +176,9 @@ const BondsPageView: FC = (props) => {
))}
) : (
- {t("bond.noSearchResults")}
+
+ {t(vm.partial ? "investment.partial" : "bond.noSearchResults")}
+
)
) : null}
diff --git a/src/pages/bonds/ui/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts
similarity index 69%
rename from src/pages/bonds/ui/BondsPage.vm.ts
rename to src/pages/bonds/ui/BondsPage/BondsPage.vm.ts
index 55f5b2f..2d21c59 100644
--- a/src/pages/bonds/ui/BondsPage.vm.ts
+++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts
@@ -1,6 +1,7 @@
import { useDebouncedValue } from "@mantine/hooks";
import { useSearchParams } from "react-router";
import { useBondSearch } from "@/entities/bond";
+import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
@@ -11,15 +12,23 @@ function useBondsPageVm() {
const query = params.get("q") ?? "";
const [debounced] = useDebouncedValue(query.trim(), SEARCH_DEBOUNCE_MS);
const result = useBondSearch(debounced);
+ const sync = useScreenSync({ screen: "bonds", query: debounced }, [result]);
const waiting = query.trim() !== debounced;
return {
query,
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
- loading: query.trim().length >= 2 && (waiting || result.isLoading),
- error: result.isError,
+ loading:
+ query.trim().length >= 2 &&
+ (waiting ||
+ (!result.data &&
+ !result.isError &&
+ result.meta?.sync.state !== "failed")),
+ error: result.isError || sync.sourceError || sync.transportError,
+ refreshing: sync.refreshing,
+ partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
- void result.refetch();
+ void sync.refresh();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
diff --git a/src/pages/card/index.ts b/src/pages/card/index.ts
index 7d3a361..db78910 100644
--- a/src/pages/card/index.ts
+++ b/src/pages/card/index.ts
@@ -1 +1 @@
-export { CardPage } from "./ui/CardPage";
+export { CardPage } from "./ui/CardPage/CardPage";
diff --git a/src/pages/card/ui/CardPage.module.css b/src/pages/card/ui/CardPage/CardPage.module.css
similarity index 100%
rename from src/pages/card/ui/CardPage.module.css
rename to src/pages/card/ui/CardPage/CardPage.module.css
diff --git a/src/pages/card/ui/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx
similarity index 100%
rename from src/pages/card/ui/CardPage.test.tsx
rename to src/pages/card/ui/CardPage/CardPage.test.tsx
diff --git a/src/pages/card/ui/CardPage.tsx b/src/pages/card/ui/CardPage/CardPage.tsx
similarity index 86%
rename from src/pages/card/ui/CardPage.tsx
rename to src/pages/card/ui/CardPage/CardPage.tsx
index 64d95b2..4ffac98 100644
--- a/src/pages/card/ui/CardPage.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.tsx
@@ -1,9 +1,9 @@
import type { FC } from "react";
import { BankCardForm } from "@/entities/bank-card";
+import { DeleteOperation } from "../DeleteOperation/DeleteOperation";
+import { OperationForm } from "../OperationForm/OperationForm";
import { CardPageView } from "./CardPage.view";
import { useCardPageVm } from "./CardPage.vm";
-import { DeleteOperation } from "./DeleteOperation";
-import { OperationForm } from "./OperationForm";
const CardPage: FC = () => {
const vm = useCardPageVm();
diff --git a/src/pages/card/ui/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx
similarity index 80%
rename from src/pages/card/ui/CardPage.view.tsx
rename to src/pages/card/ui/CardPage/CardPage.view.tsx
index b04d6c1..50698e9 100644
--- a/src/pages/card/ui/CardPage.view.tsx
+++ b/src/pages/card/ui/CardPage/CardPage.view.tsx
@@ -10,6 +10,8 @@ import {
Text,
Title,
} from "@mantine/core";
+import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons";
+import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui";
import { type FC, useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -20,7 +22,6 @@ import {
BankPeriodPicker,
BankSummaryView,
} from "@/entities/bank-card";
-import { EntryCard, LoadingSkeleton, StateView } from "@/shared/ui";
import classes from "./CardPage.module.css";
import type { CardPageVm } from "./CardPage.vm";
@@ -106,19 +107,7 @@ const CardPageView: FC = (props) => {
title={t("bank.editCard")}
onClick={() => vm.setEditingCard(true)}
>
-
+
= (props) => {
loading={vm.archivePending}
onClick={() => void vm.toggleArchive()}
>
-
+ {card.archivedAt ? (
+
+ ) : (
+
+ )}
@@ -196,18 +183,7 @@ const CardPageView: FC = (props) => {
disabled={Boolean(card.archivedAt) || !vm.cards.data}
onClick={() => vm.setOperation("new")}
>
-
+