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")} + + {props.partial ? ( + {t("investment.partial")} + ) : null} {props.refreshing ? ( ) : null} {props.refreshError ? ( - - {t("accounts.refreshError")} + + {t( + props.sourceError + ? "investment.syncFailed" + : "accounts.refreshError", + )} @@ -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")} + = (props) => { ) : null} {refreshError ? ( - + - {t("income.refreshError")} + + {t( + props.sourceError + ? "investment.syncFailed" + : "income.refreshError", + )} + ) : 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); + }} + /> + + {submitted + ? `Применено: ${submitted.from} – ${submitted.to}` + : "Период не применён"} + + + ); +}; + +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")} + + {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", + )} @@ -120,12 +140,12 @@ const AccountOperationsPageLoaded: FC = ( operation={operation} /> )) - ) : ( + ) : !props.partial ? ( - )} + ) : null} {hasNext || hasPrevious ? ( {refreshing ? ( @@ -24,12 +32,15 @@ const PortfolioRefreshState: FC = (props) => { ) : null} {refreshError ? ( - + - {t("portfolio.refreshError")} - + + {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 = () => { - @@ -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")} + + {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")} > - + ({ value, label: t(`bank.kinds.${value}`) }), - )} + data={props.kinds} {...field} disabled={isSubmitting || props.editing} onChange={(value) => { @@ -99,24 +106,22 @@ const OperationFields: FC = (props) => { label={t("bank.date")} required valueFormat="DD.MM.YYYY" - value={ - bankDateSchema.safeParse(field.value).success ? field.value : null - } + value={props.validDate(field.value) ? field.value : null} dateParser={(value) => - bankDateSchema.safeParse(bankDateInputValue(value)).success - ? bankDateInputValue(value) + props.validDate(props.dateInputValue(value)) + ? props.dateInputValue(value) : null } fixOnBlur={false} onInput={(event) => - field.onChange(bankDateInputValue(event.currentTarget.value)) + field.onChange(props.dateInputValue(event.currentTarget.value)) } onChange={(value) => field.onChange(value ?? "")} onBlur={(event) => { - field.onChange(bankDateInputValue(event.currentTarget.value)); + field.onChange(props.dateInputValue(event.currentTarget.value)); field.onBlur(); }} - maxDate={bankToday()} + maxDate={props.maxDate()} popoverProps={{ withinPortal: false, middlewares: { shift: { crossAxis: true } }, @@ -153,7 +158,6 @@ const OperationFields: FC = (props) => { type RefundFieldsProps = { editing: boolean; expenses: Choice[]; - selected?: Choice; onSelect: (id: string) => void; limit: BankOperation["availableRefund"] | undefined; }; @@ -172,14 +176,7 @@ const RefundFields: FC = (props) => {