Compare commits

..
4 Commits
Author SHA1 Message Date
ksv741 95fdc8759e ci: reduce duplicate actions and serialize checks
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m2s
2026-09-28 14:18:16 +03:00
codex-bot c1b08735e3 refactor: simplify resource queries and layout tests
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 5m51s
2026-09-27 18:53:12 +03:00
codex-bot 5e076470aa fix: resolve frontend audit findings (#39)
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m27s
2026-09-27 12:11:44 +03:00
ksv741 f6db28defa fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
2026-09-27 11:38:20 +03:00
64 changed files with 2605 additions and 398 deletions
+10 -7
View File
@@ -22,13 +22,7 @@
!packages/ui/vite.config.ts
!packages/ui/tsconfig.json
!packages/ui/src/
!packages/ui/src/theme.ts
!packages/ui/src/styles.css
!packages/ui/src/index.ts
!packages/ui/src/StateView.tsx
!packages/ui/src/LoadingSkeleton.tsx
!packages/ui/src/EntryCard.tsx
!packages/ui/src/EntryCard.module.css
!packages/ui/src/**
!packages/icons/
!packages/icons/package.json
!packages/icons/tsconfig.json
@@ -40,3 +34,12 @@
!packages/icons/src/svg/AddIcon.tsx
!packages/icons/src/svg/EditIcon.tsx
!packages/icons/src/svg/DeleteIcon.tsx
# Keep local dependencies, outputs and secrets out of allowed source trees.
**/node_modules
**/dist
**/coverage
**/storybook-static
**/.env*
**/*.pem
**/*.key
-26
View File
@@ -1,26 +0,0 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
+2
View File
@@ -30,6 +30,8 @@ jobs:
- run: npm ci
env:
HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- run: npm run verify
env:
KNIP_DISABLE_RAW_TRANSFER: '1'
-33
View File
@@ -1,33 +0,0 @@
name: Frontend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+66 -2
View File
@@ -2,12 +2,74 @@ name: Frontend verify
on:
push:
branches:
- '**'
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened, edited]
schedule:
- cron: '17 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: frontend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
security:
needs: commits
if: >-
!cancelled() &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
verify:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
@@ -21,6 +83,8 @@ jobs:
- run: npm ci
env:
HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- name: Run all frontend gates
run: npm run verify
env:
+1
View File
@@ -12,6 +12,7 @@ dist
storybook-static
dist-ssr
coverage
.vitest
.scannerwork
*.local
+25 -2
View File
@@ -75,20 +75,43 @@ Service worker кеширует только статическую оболоч
```bash
nvm use --silent
npm ci
npx --no-install playwright install chromium
npm run verify
```
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
и его заглушки layout API. Только браузерная проверка:
`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите
установку Chromium; CI устанавливает его с `--with-deps` для Linux.
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
исключений frontend нет. Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Frontend verify / commits (pull_request)`,
`Frontend verify / security (pull_request)` и
`Frontend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`.
Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого
релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения
+11 -1
View File
@@ -1,5 +1,11 @@
module.exports = {
forbidden: [
{
name: "no-unresolved",
severity: "error",
from: {},
to: { couldNotResolve: true },
},
{
name: "packages-must-not-depend-on-app",
severity: "error",
@@ -57,7 +63,11 @@ module.exports = {
options: {
exclude: "^packages/[^/]+/dist/",
doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" },
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
enhancedResolveOptions: {
exportsFields: ["exports"],
conditionNames: ["import", "require", "node", "default"],
},
tsPreCompilationDeps: true,
},
};
+201
View File
@@ -49,6 +49,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"@vitest/browser-playwright": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.0",
@@ -59,6 +60,7 @@
"lint-staged": "17.5.1",
"msw": "2.15.0",
"openapi-typescript": "7.13.0",
"playwright": "1.63.0",
"release-it": "21.0.2",
"storybook": "10.6.0",
"typescript": "5.9.3",
@@ -853,6 +855,13 @@
"node": ">=14.21.3"
}
},
"node_modules/@blazediff/core": {
"version": "1.10.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@blazediff/core/-/core-1.10.0.tgz",
"integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -3732,6 +3741,13 @@
"url": "https://github.com/phun-ky/typeof?sponsor=1"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -5376,6 +5392,54 @@
}
}
},
"node_modules/@vitest/browser": {
"version": "5.0.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser/-/browser-5.0.0.tgz",
"integrity": "sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@blazediff/core": "1.10.0",
"@vitest/mocker": "5.0.0",
"@vitest/ui": "5.0.0",
"@vitest/utils": "5.0.0",
"magic-string": "^1.2.3",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.1.1",
"ws": "^8.21.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "5.0.0"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "5.0.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser-playwright/-/browser-playwright-5.0.0.tgz",
"integrity": "sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/browser": "5.0.0",
"@vitest/mocker": "5.0.0",
"tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "5.0.0"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/coverage-v8": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz",
@@ -5456,6 +5520,19 @@
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "5.0.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/pretty-format/-/pretty-format-5.0.0.tgz",
"integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
@@ -5466,6 +5543,42 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/ui": {
"version": "5.0.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/ui/-/ui-5.0.0.tgz",
"integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "5.0.0",
"fflate": "^0.8.3",
"flatted": "^3.4.4",
"pathe": "^2.0.3",
"sirv": "^3.0.2",
"tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "5.0.0"
}
},
"node_modules/@vitest/utils": {
"version": "5.0.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/utils/-/utils-5.0.0.tgz",
"integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "5.0.0",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.1"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@wealthledger/icons": {
"resolved": "packages/icons",
"link": true
@@ -7606,6 +7719,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -7646,6 +7766,13 @@
"node": ">=10"
}
},
"node_modules/flatted": {
"version": "3.4.4",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/flatted/-/flatted-3.4.4.tgz",
"integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
"dev": true,
"license": "ISC"
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -9946,6 +10073,16 @@
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
"license": "MIT"
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10749,6 +10886,35 @@
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -10759,6 +10925,16 @@
"node": ">=4"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -12172,6 +12348,21 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -13643,6 +13834,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+3 -1
View File
@@ -21,7 +21,7 @@
"openapi:check": "node scripts/check-openapi.mjs",
"knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
"prepare": "husky",
"preview": "vite preview",
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
@@ -72,6 +72,7 @@
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"@vitest/browser-playwright": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.0",
@@ -82,6 +83,7 @@
"lint-staged": "17.5.1",
"msw": "2.15.0",
"openapi-typescript": "7.13.0",
"playwright": "1.63.0",
"release-it": "21.0.2",
"storybook": "10.6.0",
"typescript": "5.9.3",
+1 -1
View File
@@ -131,7 +131,7 @@
},
"muted": {
"color": "gray",
"shade": 6,
"shade": 7,
"description": "Вторичный текст; проверяйте контраст при выборе фона"
},
"border": {
+11 -1
View File
@@ -4,7 +4,7 @@ import { assert, describe, expect, it } from "vitest";
import { EntryCard } from "./EntryCard";
describe("EntryCard", () => {
it("keeps the fallback after an image fails", () => {
it("keeps a failed image fallback until a new source is supplied", () => {
const props = {
title: "Пример",
metadata: "Описание",
@@ -31,5 +31,15 @@ describe("EntryCard", () => {
expect(container.querySelector("img")).toBeNull();
expect(screen.getByText("У")).toBeInTheDocument();
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
rerender(
<MantineProvider>
<EntryCard {...props} imageSrc="/replacement-logo.png" />
</MantineProvider>,
);
expect(container.querySelector("img")).toHaveAttribute(
"src",
"/replacement-logo.png",
);
expect(screen.queryByText("У")).not.toBeInTheDocument();
});
});
+2 -4
View File
@@ -1,5 +1,5 @@
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, type ReactNode, useState } from "react";
import type { FC, ReactNode } from "react";
import classes from "./EntryCard.module.css";
type EntryCardProps = {
@@ -17,7 +17,6 @@ type EntryCardProps = {
};
const EntryCard: FC<EntryCardProps> = (props) => {
const [imageFailed, setImageFailed] = useState(false);
return (
<Paper
className={classes.row}
@@ -35,8 +34,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
aria-hidden="true"
radius="md"
size={40}
src={imageFailed ? null : props.imageSrc}
onError={() => setImageFailed(true)}
src={props.imageSrc}
>
{props.icon ?? props.initial}
</Avatar>
+3
View File
@@ -25,5 +25,8 @@ describe("design theme adapter", () => {
expect(Object.keys(variables)).toHaveLength(
Object.keys(source.color.roles).length,
);
expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
variables["--wl-color-muted"],
);
});
});
+3 -1
View File
@@ -79,7 +79,9 @@ const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
getThemeColor(reference, resolvedTheme),
]),
),
light: {},
light: {
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
},
dark: {},
});
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, waitFor } from "@testing-library/react";
import type { FC } from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { type FC, lazy, Suspense } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
@@ -52,6 +52,74 @@ function renderBoundary(path: string) {
}
describe("RouteErrorBoundary", () => {
it.each(["/accounts", "/login"])(
"recovers a transient failure through the fallback on %s itself",
async (path) => {
authenticated = path === "/accounts";
let failing = true;
const RecoverableRoute: FC = () => {
if (failing) throw new Error("temporary failure");
return <div>recovered route</div>;
};
render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteErrorBoundary>
<RecoverableRoute />
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
const action = screen.getByRole("link", { name: "Продолжить" });
failing = false;
fireEvent.click(action);
expect(await screen.findByText("recovered route")).toBeInTheDocument();
},
);
it("keeps a persistent failure recoverable without an automatic reset loop", () => {
authenticated = true;
renderBoundary("/accounts");
fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
expect(
screen.getByRole("heading", { name: "Не удалось открыть страницу" }),
).toBeInTheDocument();
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Перезагрузить страницу" }),
).toHaveAttribute("href", "/accounts");
});
it("offers document navigation when React.lazy caches a rejected chunk", async () => {
authenticated = false;
const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
const Chunk = lazy(load);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/login"]}>
<RouteErrorBoundary>
<Suspense fallback={<div>loading chunk</div>}>
<Chunk />
</Suspense>
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.click(await screen.findByRole("link", { name: "Продолжить" }));
expect(load).toHaveBeenCalledTimes(1);
const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let intercepted = true;
const preventNavigation = (event: MouseEvent) => {
intercepted = event.defaultPrevented;
event.preventDefault();
};
document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload);
expect(intercepted).toBe(false);
});
it("offers login after a public route error without error details", () => {
authenticated = false;
renderBoundary("/login");
@@ -51,17 +51,23 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
<Button component={Link} to={to}>
{t("routeError.action")}
</Button>
<Button component="a" href={to} variant="default">
{t("routeError.reload")}
</Button>
</Stack>
);
};
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { pathname, search } = useLocation();
const { pathname, search, key } = useLocation();
const { user } = useAuth();
return (
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
<Boundary
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
{children}
</Boundary>
);
@@ -16,7 +16,7 @@ function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
}
function bankMonth(value: string | null) {
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
return value && value >= "0001-01" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
@@ -0,0 +1,25 @@
import { afterEach, expect, it, vi } from "vitest";
import { bankPeriod } from "./period";
afterEach(() => vi.useRealTimers());
it.each([
["0001-01", "0001-01-01", "0001-01-31"],
["0099-12", "0099-12-01", "0099-12-31"],
["2026-09", "2026-09-01", "2026-09-30"],
["2024-02", "2024-02-01", "2024-02-29"],
["0004-02", "0004-02-01", "0004-02-29"],
])("keeps the ISO year for legacy month %s", (month, from, to) => {
expect(bankPeriod(new URLSearchParams({ month }))).toEqual({ from, to });
});
it("rejects zero year consistently in legacy and explicit periods", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
expect(bankPeriod(new URLSearchParams(query))).toEqual({
from: "2026-09-01",
to: "2026-09-30",
});
}
});
+4 -2
View File
@@ -1,10 +1,12 @@
import dayjs from "dayjs";
import { z } from "zod";
import { bankMonth } from "./form-validation";
const calendarDate = z.string().date();
const ISO_DATE_LENGTH = 10;
function bankPeriod(params: URLSearchParams) {
const month = bankMonth(params.get("month"));
const monthEnd = new Date(`${month}-01T00:00:00Z`);
monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0);
const from = params.get("from");
const to = params.get("to");
return from &&
@@ -16,7 +18,7 @@ function bankPeriod(params: URLSearchParams) {
? { from, to }
: {
from: `${month}-01`,
to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"),
to: monthEnd.toISOString().slice(0, ISO_DATE_LENGTH),
};
}
@@ -1,6 +1,12 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it } from "vitest";
import { configureAuthClient } from "@/shared/api";
@@ -9,6 +15,149 @@ import { server } from "@/test/server";
import { CategoryManager } from "./CategoryManager";
describe("CategoryManager", () => {
it("ignores a late reload for A while editing B and submits B's draft and version", async () => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
const categories = [
{ id: "a", name: "Первая", isDefault: false, version: 1 },
{ id: "b", name: "Вторая", isDefault: false, version: 4 },
];
let reads = 0;
let release = () => {};
const reloaded = new Promise<void>((resolve) => {
release = resolve;
});
const patches: { id: unknown; body: unknown }[] = [];
server.use(
http.get("*/api/v1/bank-cards/categories", async () => {
reads++;
if (reads === 3) await reloaded;
return HttpResponse.json(
categories.map((category) =>
category.id === "a" && reads > 1
? {
...category,
name: reads >= 3 ? "Первая обновлена" : category.name,
version: 2,
}
: category,
),
);
}),
http.patch(
"*/api/v1/bank-cards/categories/:id",
async ({ request, params }) => {
patches.push({ id: params.id, body: await request.json() });
return patches.length === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json(categories[1]);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
(await screen.findAllByRole("button", { name: "Переименовать" }))[0],
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await waitFor(() => expect(reads).toBe(3));
fireEvent.click(
screen.getAllByRole("button", { name: "Переименовать" })[1],
);
fireEvent.change(screen.getByLabelText(/Название категории/), {
target: { value: "Новая вторая" },
});
await act(async () => {
release();
await reloaded;
});
await screen.findByText("Первая обновлена");
await waitFor(() =>
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument(),
);
expect(screen.getByLabelText(/Название категории/)).toHaveValue(
"Новая вторая",
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
await waitFor(() =>
expect(patches).toEqual([
{ id: "a", body: { name: "Первая", version: 1 } },
{ id: "b", body: { name: "Новая вторая", version: 4 } },
]),
);
});
it.each(["Отмена", "Сохранить"])(
"clears reloaded recovery after %s",
async (action) => {
configureAuthClient({
accessToken: () => "test",
refresh: async () => false,
clear: () => {},
});
let version = 1;
let writes = 0;
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([
{ id: "a", name: "Первая", isDefault: false, version },
]),
),
http.patch("*/api/v1/bank-cards/categories/a", () => {
version = 2;
return ++writes === 1
? HttpResponse.json(
{ code: "BANK_VERSION_CONFLICT" },
{ status: 409 },
)
: HttpResponse.json({});
}),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<CategoryManager onClose={() => {}} />
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(
await screen.findByRole("button", { name: "Переименовать" }),
);
fireEvent.click(screen.getByRole("button", { name: "Сохранить" }));
fireEvent.click(
await screen.findByRole("button", { name: "Перечитать запись" }),
);
await screen.findByRole("button", {
name: /Использовать актуальную версию/,
});
fireEvent.click(screen.getByRole("button", { name: action }));
await screen.findByRole("button", { name: "Добавить категорию" });
expect(
screen.queryByRole("button", {
name: /Использовать актуальную версию/,
}),
).not.toBeInTheDocument();
},
);
it("rejects duplicate case-insensitive and too long names then renames default identity", async () => {
configureAuthClient({
accessToken: () => "test",
@@ -8,7 +8,7 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { type FC, useState } from "react";
import { type FC, useRef, useState } from "react";
import {
Controller,
FormProvider,
@@ -60,6 +60,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
const categories = useBankCategories();
const mutation = useBankMutations();
const [editing, setEditing] = useState<ExpenseCategory>();
const editSession = useRef(0);
const [error, setError] = useState<unknown>();
const [latest, setLatest] = useState<ExpenseCategory>();
const methods = useForm<CategoryValues>({
@@ -114,7 +115,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
});
if (result) props.onCreated?.(result.id);
}
editSession.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
} catch (failure) {
setError(failure);
@@ -138,6 +141,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="subtle"
onClick={() => {
editSession.current++;
setEditing(category);
methods.reset({ name: category.name });
setError(undefined);
@@ -167,7 +171,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="default"
onClick={async () => {
const session = editSession.current;
const response = await categories.refetch();
if (session !== editSession.current) return;
if (response.error) setError(response.error);
else
setLatest(
@@ -180,7 +186,7 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
)}
</>
) : null}
{latest && (
{latest && latest.id === editing?.id && (
<>
<Text>{t("bank.versionHint", { details: latest.name })}</Text>
<Button
@@ -202,7 +208,9 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => {
<Button
variant="default"
onClick={() => {
editSession.current++;
setEditing(undefined);
setLatest(undefined);
methods.reset({ name: "" });
setError(undefined);
}}
@@ -8,7 +8,7 @@ import {
} from "./useBrokerageOperations";
describe("useBrokerageOperations", () => {
it("normalizes query parameters and loads a page without retries", async () => {
it("normalizes categories, preserves the cursor and loads a page", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
@@ -79,8 +79,49 @@ describe("persistent resource query lifecycle", () => {
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed"));
expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(false);
expect(result.current.status).toBe("pending");
expect(result.current.isPending).toBe(true);
expect(result.current.isLoading).toBe(false);
expect(result.current.transport).toEqual({
status: "success",
fetchStatus: "idle",
error: null,
});
expect(result.current.meta?.availability).toBe("missing");
});
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
auth();
let ready = false;
const refreshed = resource(snapshot, { stateVersion: "ready" });
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(ready ? refreshed : resource(null)),
),
);
const { result } = renderHook(() => usePortfolio("a"), {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() =>
expect(result.current.transport.status).toBe("success"),
);
await act(async () => {
const missing = await result.current.refetch();
expect(missing.status).toBe("success");
expect(missing.data).toEqual(resource(null));
expect(missing.data?.data).toBeNull();
ready = true;
const loaded = await result.current.refetch();
expect(loaded.data).toEqual(refreshed);
expect(loaded.data?.data?.totalsByCurrency).toEqual(
snapshot.totalsByCurrency,
);
});
await waitFor(() => expect(result.current.data).toEqual(snapshot));
expect(result.current.meta).toEqual(refreshed.meta);
expect(result.current.status).toBe("success");
expect(result.current.isSuccess).toBe(true);
expect(result.current.isPending).toBe(false);
});
it("rejects a late ordinary response when an observe response for that resource arrived first", async () => {
auth();
let release!: () => void;
@@ -8,7 +8,7 @@ import { server } from "@/test/server";
import { usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
it("uses the account endpoint with a 30 second cache and no retry", async () => {
it("loads the account portfolio", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
+1
View File
@@ -1,4 +1,5 @@
export { section } from "./model/section";
export { useInstrumentOperations } from "./model/useInstrumentOperations";
export { InstrumentChart } from "./ui/InstrumentChart.view";
export { InstrumentEvents } from "./ui/InstrumentEvents.view";
export { InstrumentPosition } from "./ui/InstrumentPosition.view";
@@ -0,0 +1,51 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { apiErrorCode } from "@/shared/api";
function useInstrumentOperations(
uid: string,
filter: string,
categories: OperationCategory[],
) {
const scope = `${uid}:${filter}`;
const [pagination, setPagination] = useState<{
scope: string;
cursors: string[];
}>({ scope, cursors: [] });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const firstPage = { instrumentUid: uid, categories, limit: 20 };
const operations = useBrokerageOperations(uid ? "all" : undefined, {
...firstPage,
cursor: cursors.at(-1),
});
const retry = (retryScreen: () => unknown) => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
setPagination({ scope, cursors: [] });
void client.invalidateQueries({
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void retryScreen();
};
return {
operations,
retry,
hasPrevious: cursors.length > 0,
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
scope,
cursors: [...cursors, operations.data.nextCursor],
});
},
};
}
export { useInstrumentOperations };
@@ -2,11 +2,13 @@ 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 { useStockQuote } from "@/entities/stock";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() {
configureAuthClient({
@@ -16,6 +18,207 @@ function auth() {
});
}
describe("page-owned screen synchronization", () => {
it.each([false, true])(
"retries a failed GET without forcing the source (cached=%s)",
async (cached) => {
auth();
const modes: (string | null)[] = [];
let unavailable = !cached;
let posts = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return unavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { stateVersion: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
return new HttpResponse(null, { status: 500 });
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
disabled,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
if (cached) {
await waitFor(() =>
expect(result.current.accounts.isSuccess).toBe(true),
);
unavailable = true;
await act(() => result.current.accounts.refetch());
}
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
unavailable = false;
await act(() => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.meta?.stateVersion).toBe(
cached ? "3" : "2",
);
expect(modes).toEqual(
cached ? [null, null, "observe"] : [null, "observe"],
);
expect(posts).toBe(0);
expect(result.current.disabled.data).toBeUndefined();
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it.each([
["logout", true],
["connection", true],
["session", false],
["connection", false],
["access", false],
] as const)(
"discards recovery after %s changes (HTTP already pending=%s)",
async (boundary, pending) => {
auth();
let release!: () => void;
const blocked = new Promise<void>((resolve) => {
release = resolve;
});
let recovering = false;
let changed = false;
server.use(
http.get("*/api/v1/brokerage-accounts", async ({ request }) => {
if (boundary === "connection" && changed && !request.signal.aborted)
return HttpResponse.json(resource([], { stateVersion: "current" }));
if (!new URL(request.url).searchParams.has("sync"))
return changed
? HttpResponse.json(resource([], { stateVersion: "current" }))
: HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 });
recovering = true;
await blocked;
return HttpResponse.json(resource([], { stateVersion: "old" }));
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const published: unknown[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success")
published.push(event.query.state.data);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
let recovery!: Promise<void>;
act(() => {
recovery = result.current.sync.retry();
});
if (pending) await waitFor(() => expect(recovering).toBe(true));
await act(async () => {
changed = true;
if (boundary === "logout") {
configureAuthClient(null);
queryClient.clear();
} else if (boundary === "session") {
auth();
queryClient.clear();
} else if (boundary === "access") {
invalidateResourceAccess(queryClient, "brokerage", {
code: "FORBIDDEN",
});
} else
commitTbankConnectionBoundary(queryClient, {
connected: true,
connectedAt: "2026-09-27T00:00:00Z",
});
release();
await recovery;
});
if (boundary === "connection")
await waitFor(() =>
expect(result.current.accounts.meta?.stateVersion).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ stateVersion: "old" }),
}),
);
unsubscribe();
},
);
it.each([false, true])(
"recovers an initial GET failure after forced sync without metadata (status retry=%s)",
async (retryStatus) => {
auth();
const modes: (string | null)[] = [];
let polls = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ id: "recover" }, { status: 202 }),
),
http.get("*/api/v1/tbank-sync/:id", () => {
polls += 1;
return retryStatus && polls === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
id: "recover",
state: "succeeded",
resources: [resource([]).meta],
});
}),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
await waitFor(() => expect(result.current.accounts.isError).toBe(true));
expect(result.current.accounts.meta).toBeUndefined();
await act(() => result.current.sync.refresh());
if (retryStatus) {
await waitFor(() =>
expect(result.current.sync.transportError).toBe(true),
);
await act(() => result.current.sync.retry());
}
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(result.current.accounts.data).toEqual([]);
expect(modes).toEqual([null, "observe"]);
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it("posts only its scope once and observes completion without importing again", async () => {
auth();
const modes: (string | null)[] = [];
@@ -1,4 +1,4 @@
import { useQueryClient } from "@tanstack/react-query";
import { type QueryKey, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import {
apiErrorCode,
@@ -12,7 +12,12 @@ import { type InvestmentScreen, useTbankSync } from "./sync";
type ResourceMeta =
paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number];
type ScreenResource = {
queryKey: QueryKey;
meta?: ResourceMeta;
transport: {
status: "pending" | "success" | "error";
fetchStatus: "idle" | "fetching" | "paused";
};
observe: (version?: string) => Promise<unknown>;
};
const STATUS_INTERVAL_MS = 1000;
@@ -81,7 +86,21 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
latest.set(dependency.resourceKey, dependency);
}
for (const resource of resources) {
if (!resource.meta) continue;
if (!resource.meta) {
if (resource.transport.fetchStatus !== "idle") continue;
const completed = statuses.find(
(status) =>
requested?.scope === scopeKey &&
status.data?.id === requested.id &&
status.data.state !== "running",
)?.data;
const key = `query:${JSON.stringify(resource.queryKey)}`;
if (completed && observed.current.get(key) !== completed.id) {
observed.current.set(key, completed.id);
void resource.observe().catch(() => undefined);
}
continue;
}
const changed = [
resource.meta,
...(resource.meta.dependencies ?? []),
@@ -103,7 +122,7 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
);
void resource.observe(own?.stateVersion).catch(() => undefined);
}
}, [client, statuses, resources]);
}, [client, statuses, resources, requested, scopeKey]);
const refreshing =
(currentMutation && mutation.isPending) ||
statuses.some(
@@ -133,8 +152,23 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
if (requestInFlight.current === scopeKey) requestInFlight.current = null;
}
};
// Retry failed HTTP reads without asking the source to import again.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => {
const failed = resources.filter(
(resource) => resource.transport.status === "error",
);
const failedStatuses = statuses.filter((status) => status.isError);
if (failed.length || failedStatuses.length) {
await Promise.allSettled([
...failed.map((resource) => resource.observe()),
...failedStatuses.map((status) => status.refetch()),
]);
} else await refresh();
};
return {
refresh,
retry,
refreshing,
transportError:
(currentMutation && mutation.isError) ||
@@ -1,11 +1,18 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { describe, expect, it, vi } from "vitest";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { tbankConnectionQueryOptions } from "../../model/connection-boundary";
import { TbankConnectionSection } from "./TbankConnectionSection";
const brokerageAccountsQueryKey = apiQueryClient.queryOptions(
@@ -78,6 +85,74 @@ function seededClient() {
}
describe("TbankConnectionSection", () => {
it("preserves cached status and a draft entered during a delayed failed refetch", async () => {
const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" };
let finishRefetch: (() => void) | undefined;
const response = new Promise<void>((resolve) => {
finishRefetch = resolve;
});
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", async () => {
reads += 1;
if (reads === 1) {
await response;
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}
return HttpResponse.json(status);
}),
);
const client = seededClient();
client.setQueryData(tbankConnectionQueryOptions().queryKey, status);
renderSection(client);
await waitFor(() => expect(reads).toBe(1));
const token = screen.getByLabelText("Token T-Bank");
fireEvent.change(token, { target: { value: "replacement-draft" } });
finishRefetch?.();
expect(
await screen.findByText(/Не удалось обновить статус подключения/),
).toBeVisible();
expect(screen.getByLabelText("Token T-Bank")).toBe(token);
expect(token).toHaveValue("replacement-draft");
expect(screen.getByText("Подключено с 14.08.2026")).toBeVisible();
expect(client.getQueryData(tbankConnectionQueryOptions().queryKey)).toEqual(
status,
);
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(reads).toBe(2));
await waitFor(() =>
expect(
screen.queryByText(/Не удалось обновить статус подключения/),
).not.toBeInTheDocument(),
);
expect(token).toHaveValue("replacement-draft");
});
it("shows a cold-start error without a form and recovers by retrying status", async () => {
let reads = 0;
server.use(
http.get("*/api/v1/tbank-connection", () => {
reads += 1;
return reads === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({ connected: false, connectedAt: null });
}),
);
renderSection(createTestQueryClient());
expect(
await screen.findByText(/Не удалось загрузить статус подключения/),
).toBeVisible();
expect(screen.queryByLabelText("Token T-Bank")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(await screen.findByLabelText("Token T-Bank")).toBeVisible();
expect(reads).toBe(2);
});
it("commits connection status and resets the complete brokerage query subtree after connecting", async () => {
const client = seededClient();
renderSection(client);
@@ -225,7 +300,12 @@ describe("TbankConnectionSection", () => {
});
});
it("preserves connected status and financial cache when deletion fails", async () => {
it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => {
let deletes = 0;
let finishDelete: (() => void) | undefined;
const response = new Promise<void>((resolve) => {
finishDelete = resolve;
});
server.use(
http.get("*/api/v1/tbank-connection", () =>
HttpResponse.json({
@@ -233,9 +313,13 @@ describe("TbankConnectionSection", () => {
connectedAt: "2026-08-14T00:00:00Z",
}),
),
http.delete("*/api/v1/tbank-connection", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
http.delete("*/api/v1/tbank-connection", async () => {
deletes += 1;
if (deletes === 1) await response;
return deletes === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: new HttpResponse(null, { status: 204 });
}),
);
const client = seededClient();
renderSection(client);
@@ -243,9 +327,30 @@ describe("TbankConnectionSection", () => {
fireEvent.click(
await screen.findByRole("button", { name: "Удалить подключение" }),
);
fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ }));
const dialog = await screen.findByRole("dialog", {
name: "Удалить подключение?",
});
await waitFor(() =>
expect(dialog.contains(document.activeElement)).toBe(true),
);
const confirm = within(dialog).getByRole("button", { name: /^Удалить$/ });
confirm.focus();
fireEvent.click(confirm);
await waitFor(() => expect(confirm).toBeDisabled());
expect(
within(dialog).getByRole("button", { name: "Закрыть" }),
).toBeDisabled();
// JSDOM does not move focus when a focused button becomes disabled.
document.body.tabIndex = -1;
document.body.focus();
document.body.removeAttribute("tabindex");
expect(document.activeElement).toBe(document.body);
finishDelete?.();
await screen.findByText("Не удалось выполнить запрос. Попробуйте ещё раз.");
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
"Не удалось выполнить запрос. Попробуйте ещё раз.",
);
expect(confirm).toHaveFocus();
expect(
screen.getByRole("button", { name: "Заменить token" }),
).toBeInTheDocument();
@@ -253,5 +358,12 @@ describe("TbankConnectionSection", () => {
expect(client.getQueryData(portfolioQueryKey)).toEqual({
source: "previous-connection",
});
fireEvent.click(within(dialog).getByRole("button", { name: /^Удалить$/ }));
await waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
);
expect(screen.getByText("Не подключено")).toBeVisible();
expect(client.getQueryData(portfolioQueryKey)).toBeUndefined();
expect(deletes).toBe(2);
});
});
@@ -11,7 +11,7 @@ const TbankConnectionSection: FC = () => {
if (viewModel.state === "loading") return <TbankConnectionLoading />;
if (viewModel.state === "error")
return <TbankConnectionError retry={viewModel.retry} />;
return <TbankConnectionSectionLoaded status={viewModel.status} />;
return <TbankConnectionSectionLoaded {...viewModel} />;
};
export { TbankConnectionSection };
@@ -5,10 +5,17 @@ function useTbankConnectionViewModel() {
const status = useAuthenticatedQuery({
...tbankConnectionQueryOptions(),
});
if (status.isPending) return { state: "loading" as const };
if (status.isError)
if (!status.data) {
if (status.isPending) return { state: "loading" as const };
return { state: "error" as const, retry: () => status.refetch() };
return { state: "loaded" as const, status: status.data };
}
return {
state: "loaded" as const,
status: status.data,
refreshing: status.isFetching,
refreshError: status.isError,
retry: () => status.refetch(),
};
}
export { useTbankConnectionViewModel };
@@ -8,7 +8,7 @@ import {
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import type { FC } from "react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { apiErrorCode, apiQueryClient } from "@/shared/api";
@@ -19,6 +19,9 @@ import {
type TbankConnectionSectionLoadedProps = {
status: TbankConnectionStatus;
refreshing: boolean;
refreshError: boolean;
retry: () => void;
};
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
@@ -28,6 +31,7 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
const { t } = useTranslation();
const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false);
const deleteButton = useRef<HTMLButtonElement>(null);
const [requestError, setRequestError] = useState<string | null>(null);
const {
register,
@@ -53,11 +57,13 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
});
setDeleteOpened(false);
},
onError: () => setRequestError("REQUEST_ERROR"),
},
);
const pending = update.isPending || remove.isPending;
const connected = status.connected;
useEffect(() => {
if (deleteOpened && remove.isError) deleteButton.current?.focus();
}, [deleteOpened, remove.isError]);
return (
<Stack mt="xl">
@@ -74,6 +80,25 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
<Text size="sm" c="dimmed">
{t("tbank.hint")}
</Text>
{props.refreshing && (
<Text role="status" size="sm" c="dimmed">
{t("tbank.refreshing")}
</Text>
)}
{props.refreshError && (
<Alert color="orange" role="status" aria-live="polite">
<Stack gap="sm">
<Text>{t("tbank.refreshError")}</Text>
<Button
variant="default"
onClick={props.retry}
disabled={props.refreshing || pending}
>
{t("tbank.retry")}
</Button>
</Stack>
</Alert>
)}
<form
onSubmit={handleSubmit(({ token }) => {
setRequestError(null);
@@ -105,7 +130,10 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
<Button
variant="outline"
color="red"
onClick={() => setDeleteOpened(true)}
onClick={() => {
remove.reset();
setDeleteOpened(true);
}}
>
{t("tbank.delete")}
</Button>
@@ -119,10 +147,15 @@ const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
title={t("tbank.deleteTitle")}
closeOnClickOutside={!pending}
closeOnEscape={!pending}
closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }}
>
<Stack>
<Text>{t("tbank.deleteText")}</Text>
{remove.isError && (
<Alert color="red">{t("tbank.requestError")}</Alert>
)}
<Button
ref={deleteButton}
color="red"
loading={remove.isPending}
disabled={pending}
@@ -1,6 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
fireEvent,
render,
renderHook,
@@ -33,11 +34,210 @@ const Navigation: FC = () => {
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
describe("Upcoming event date filters", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountEventsPageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2099-02-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it.each([
"from=2099-02-01&to=2099-02-28",
"from=2099-02-01",
"to=2099-02-28",
])(
"keeps event calendar dates in GET and source sync requests: %s",
async (search) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
}),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
const period = Object.fromEntries(new URLSearchParams(search));
const { wrapper: QueryWrapper } = createQueryWrapper();
const { result } = renderHook(
() => useAccountEventsPageViewModel("all"),
{
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={[`?${search}`]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
},
);
await waitFor(() => expect(result.current.state).toBe("error"));
expect(requested).toEqual([`?${search}`]);
expect(syncRequests).toEqual([]);
act(() => {
if (result.current.state === "error") result.current.retry();
});
await waitFor(() =>
expect(syncRequests).toEqual([
{ screen: "events", accountId: "all", ...period },
]),
);
},
);
it.each(["account-1", "all"])(
"normalizes invalid links and history while preserving one-sided future dates for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const searches: string[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) => {
searches.push(new URL(request.url).search);
return HttpResponse.json(resource({ events: [] }));
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2099-02-28",
"?from=2099-02-30",
"?from=2099-03-01&to=2099-02-28",
"?from=2099-02-01",
"?to=2099-02-28",
].map((search) => `/accounts/${accountId}/events${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("URL")).toHaveTextContent(
"?from=2099-02-01",
),
);
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099");
expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]);
},
);
it.each(["account-1", "all"])(
"lets users reset filters after a request failure for %s",
async (accountId) => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/events",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(resource({ events: [] })),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[`/accounts/${accountId}/events?from=2099-02-01`]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/events"
element={<AccountEventsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it.each(["account-1", "all"])(
"applies dates only on submit, restores URL dates, and resets from empty results for %s",
async (accountId) => {
@@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() }));
vi.mock("@/features/tbank-connection", () => ({
useScreenSync: () => ({
refresh,
retry: refresh,
refreshing: false,
sourceError: false,
transportError: false,
@@ -78,7 +79,7 @@ describe("AccountEventsPage", () => {
refetch: vi.fn(),
} as never);
renderPage();
expect(query).toHaveBeenCalledWith(undefined, {});
expect(query).toHaveBeenCalledWith("", {});
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
"aria-busy",
"true",
@@ -10,7 +10,7 @@ const AccountEventsPage: FC<AccountEventsPageProps> = (props) => {
const viewModel = useAccountEventsPageViewModel(props.accountId);
if (viewModel.state === "loading") return <AccountEventsPageLoading />;
if (viewModel.state === "error")
return <AccountEventsPageError retry={viewModel.retry} />;
return <AccountEventsPageError {...viewModel} />;
return <AccountEventsPageLoaded {...viewModel} />;
};
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageEvent,
useBrokerageEvents,
@@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection";
type AccountEventsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
events: BrokerageEvent[];
@@ -31,12 +32,22 @@ function useAccountEventsPageViewModel(
scopeId?: string,
): AccountEventsPageViewModel {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const [draftFrom, setDraftFrom] = useState<string | null>(appliedFrom);
const [draftTo, setDraftTo] = useState<string | null>(appliedTo);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
@@ -54,12 +65,18 @@ function useAccountEventsPageViewModel(
},
[events],
);
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
};
if (events.isLoading) return { state: "loading" };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data)
return {
state: "error",
onReset,
retry: () => {
void sync.refresh();
void sync.retry();
},
};
if (!events.data) return { state: "loading" };
@@ -72,26 +89,34 @@ function useAccountEventsPageViewModel(
refreshing: events.isFetching || sync.refreshing,
refreshError: events.isError || sync.sourceError || sync.transportError,
retry: () => {
void sync.refresh();
void sync.retry();
},
draftFrom,
draftTo,
canApply:
(draftFrom !== appliedFrom || draftTo !== appliedTo) &&
(!draftFrom || !draftTo || draftFrom <= draftTo),
isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onApply: () =>
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
}),
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setSearchParams({});
});
},
onReset,
};
}
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
export { type AccountEventsPageViewModel, useAccountEventsPageViewModel };
@@ -1,9 +1,9 @@
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
type AccountEventsPageErrorProps = { retry: () => void };
type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void };
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
const { t } = useTranslation();
@@ -12,7 +12,14 @@ const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
error
title={t("events.errorTitle")}
text={t("events.errorText")}
action={<Button onClick={props.retry}>{t("events.retry")}</Button>}
action={
<Group>
<Button onClick={props.retry}>{t("events.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/>
);
};
@@ -6,6 +6,7 @@ import {
act,
fireEvent,
render,
renderHook,
screen,
waitFor,
within,
@@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { isDate } from "../../model/period";
import { AccountIncomePage } from "./AccountIncomePage";
import { toIncomeDisplayModel } from "./AccountIncomePage.vm";
import {
toIncomeDisplayModel,
useAccountIncomePageViewModel,
} from "./AccountIncomePage.vm";
import { IncomePayments } from "./components/IncomePayments.view";
const analytics = {
@@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") {
}
describe("AccountIncomePage", () => {
it("keeps reads and sync disabled without an account scope", async () => {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
});
const requests: string[] = [];
server.use(
http.all("*/api/v1/*", ({ request }) => {
requests.push(request.url);
return new HttpResponse(null, { status: 503 });
}),
);
const { wrapper: QueryWrapper, queryClient } = createQueryWrapper();
const { result } = renderHook(() => useAccountIncomePageViewModel(), {
wrapper: ({ children }) => (
<QueryWrapper>
<MemoryRouter initialEntries={["?from=2024-02-29&to=2024-03-01"]}>
{children}
</MemoryRouter>
</QueryWrapper>
),
});
await act(async () => {});
expect(result.current).toEqual({ state: "loading" });
expect(queryClient.isFetching()).toBe(0);
expect(requests).toEqual([]);
});
it("keeps income calendar dates in GET and source sync requests", async () => {
const requested: string[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/income-analytics",
({ request }) => {
requested.push(new URL(request.url).search);
return HttpResponse.json(
resource(null, {
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
renderPage("from=2024-02-29&to=2024-03-01");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "income",
accountId: "account-1",
from: "2024-02-29",
to: "2024-03-01",
},
]),
);
});
it("requests a multi-year period from the URL without resetting it", async () => {
const requested: string[] = [];
server.use(
@@ -431,20 +508,18 @@ describe("AccountIncomePage", () => {
});
it("shows the unavailable year state without falling back to the previous period", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "selected" as const },
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => {
});
});
it("maps comparison notices by role regardless of dependency order or resource key", () => {
const selected = { ...resource(analytics).meta, role: "selected" as const };
const previous = {
...resource(null).meta,
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
};
const year = {
...resource(null).meta,
resourceKey: "opaque/previous-looking-key",
role: "year" as const,
unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY",
};
for (const dependencies of [
[selected, previous, year],
[year, selected, previous],
]) {
const display = toIncomeDisplayModel(analytics, {
...resource(analytics).meta,
dependencies,
});
expect(display?.previousNotice).toBe("comparisonFailed");
expect(display?.yearNotice).toBe("yearUnavailable");
expect(display?.previousComparison).not.toBeNull();
}
});
it.each([false, true])(
"shows unavailable comparisons for absent roles without positional fallback (legacy=%s)",
async (legacy) => {
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(
{ ...analytics, previous: null, yearComparison: null },
{
dependencies: legacy
? [
resource(null).meta,
resource(null).meta,
resource(null).meta,
]
: [],
},
),
),
),
);
renderPage();
expect(
await screen.findByText("Сравнение с предыдущим периодом недоступно."),
).toBeVisible();
expect(
screen.getByText("Сравнение с годом ранее недоступно."),
).toBeVisible();
expect(
screen.queryByText("Период сравнения ещё не загружен полностью."),
).not.toBeInTheDocument();
expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible();
},
);
it("shows selected income before comparison coverage without inventing a zero baseline", async () => {
const dependencies = (["year", "selected", "previous"] as const).map(
(role) => ({
...resource(role === "selected" ? analytics : null).meta,
role,
}),
);
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource({ ...analytics, previous: null, yearComparison: null }),
resource(
{ ...analytics, previous: null, yearComparison: null },
{ dependencies },
),
),
),
);
@@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba
});
it("keeps complete previous comparison while year source failed without inventing its amounts", async () => {
const dependencies = [
{ ...resource(analytics).meta, role: "previous" as const },
{
...resource(null).meta,
role: "year" as const,
sync: {
id: null,
state: "failed" as const,
errorCode: "TBANK_UNAVAILABLE",
},
},
{ ...resource(analytics).meta, role: "selected" as const },
];
server.use(
http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () =>
HttpResponse.json(
resource(analytics, {
dependencies: [
resource(analytics).meta,
resource(analytics).meta,
{
...resource(null).meta,
sync: {
id: null,
state: "failed",
errorCode: "TBANK_UNAVAILABLE",
},
},
],
}),
),
HttpResponse.json(resource(analytics, { dependencies })),
),
);
renderPage();
@@ -22,6 +22,7 @@ type IncomeMeta = ReturnType<typeof useIncomeAnalytics>["meta"];
type ComparisonNotice =
| "comparisonPending"
| "comparisonFailed"
| "previousUnavailable"
| "yearUnavailable";
type IncomeDisplayModel = {
@@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = (
scopeId?: string,
): AccountIncomePageViewModel => {
const { accountId: routeAccountId } = useParams();
const accountId = scopeId ?? routeAccountId;
const accountId = scopeId ?? routeAccountId ?? "";
const [searchParams, setSearchParams] = useSearchParams();
const requested = useMemo(
() => ({
@@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = (
};
if (analytics.isLoading || !analytics.data) {
if (analytics.isError || analytics.meta?.sync.state === "failed")
return { state: "error", retry: sync.refresh, period };
return { state: "error", retry: sync.retry, period };
return { state: "loading" };
}
const display = toIncomeDisplayModel(analytics.data, analytics.meta);
@@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = (
partial: analytics.meta?.availability !== "complete",
refreshing: analytics.isFetching || sync.refreshing,
refreshError: analytics.isError || sync.sourceError || sync.transportError,
retry: sync.refresh,
retry: sync.retry,
form,
onSubmit,
onReset,
@@ -236,14 +237,14 @@ function toIncomeDisplayModel(
if (instrument.includedCount !== instrument.receiptCount) return null;
}
// The income endpoint returns selected, previous and year dependencies in order.
const [, previous, year] = meta?.dependencies ?? [];
const previous = meta?.dependencies?.find(({ role }) => role === "previous");
const year = meta?.dependencies?.find(({ role }) => role === "year");
return {
entries,
previousComparison: comparisonDisplay(analytics.period, analytics.previous),
comparison: comparisonDisplay(analytics.period, analytics.yearComparison),
previousNotice: comparisonNotice(previous),
yearNotice: comparisonNotice(year),
previousNotice: comparisonNotice(previous, "previousUnavailable"),
yearNotice: comparisonNotice(year, "yearUnavailable"),
};
}
@@ -251,9 +252,10 @@ function comparisonNotice(
dependency:
| NonNullable<NonNullable<IncomeMeta>["dependencies"]>[number]
| undefined,
unavailable: "previousUnavailable" | "yearUnavailable",
): ComparisonNotice {
if (dependency?.unavailableReason) return "yearUnavailable";
return dependency?.sync.state === "failed"
if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed"
? "comparisonFailed"
: "comparisonPending";
}
@@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter, Route, Routes } from "react-router";
import type { FC } from "react";
import {
MemoryRouter,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router";
import { describe, expect, it, vi } from "vitest";
import {
type BrokerageOperation,
@@ -32,6 +39,22 @@ const operation: BrokerageOperation = {
commission: { currency: "RUB", value: "1" },
};
const Navigation: FC = () => {
const location = useLocation();
const navigate = useNavigate();
return (
<>
<output aria-label="URL">{location.search}</output>
<button type="button" onClick={() => navigate(-1)}>
History back
</button>
<button type="button" onClick={() => navigate(1)}>
History forward
</button>
</>
);
};
function configureTestAuth() {
configureAuthClient({
accessToken: () => "token",
@@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => {
});
describe("AccountOperationsPage", () => {
it.each(["account-1", "all"])(
"normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s",
async (accountId) => {
configureTestAuth();
const searches: URLSearchParams[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
searches.push(new URL(request.url).searchParams);
return HttpResponse.json(
resource({
items: [operation],
nextCursor: "next",
hasNext: true,
}),
);
},
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialIndex={0}
initialEntries={[
"?from=bad&to=2026-02-28&categories=trade",
"?from=2026-02-30&categories=trade",
"?from=2026-03-01&to=2026-02-28&categories=trade",
"?from=2026-02-01&categories=trade",
"?to=2026-02-28&categories=trade",
].map((search) => `/accounts/${accountId}/operations${search}`)}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByLabelText("Период");
expect(searches[0].get("from")).toBeNull();
expect(searches[0].get("to")).toBeNull();
for (const expected of [
"?categories=trade",
"?categories=trade",
"?from=2026-02-01&categories=trade",
]) {
fireEvent.click(
screen.getByRole("button", { name: "History forward" }),
);
await waitFor(() =>
expect(screen.getByLabelText("URL").textContent).toBe(expected),
);
await screen.findByLabelText("Период");
}
expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z");
expect(searches.at(-1)?.get("to")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next"));
await screen.findByLabelText("Период");
fireEvent.click(screen.getByRole("button", { name: "History forward" }));
await screen.findByLabelText("Период");
await waitFor(() =>
expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"),
);
expect(searches.at(-1)?.get("from")).toBeNull();
expect(searches.at(-1)?.get("cursor")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "History back" }));
await waitFor(() =>
expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"),
);
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
expect(searches.map((query) => query.get("from"))).not.toContain(
"badT00:00:00.000Z",
);
expect(
searches.every((query) => query.get("categories") === "trade"),
).toBe(true);
},
);
it.each(["account-1", "all"])(
"allows resetting filters after a request failure for %s",
async (accountId) => {
configureTestAuth();
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) =>
new URL(request.url).searchParams.has("from")
? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 })
: HttpResponse.json(
resource({ items: [], hasNext: false, nextCursor: null }),
),
),
);
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider env="test">
<MemoryRouter
initialEntries={[
`/accounts/${accountId}/operations?from=2026-02-01`,
]}
>
<Navigation />
<Routes>
<Route
path="/accounts/:accountId/operations"
element={<AccountOperationsPage />}
/>
</Routes>
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
await screen.findByRole("button", { name: "Повторить" });
fireEvent.click(screen.getByRole("button", { name: "Сбросить" }));
await screen.findByLabelText("Период");
expect(screen.getByLabelText("URL").textContent).toBe("");
},
);
it("resets cursor pagination when filters change", async () => {
configureTestAuth();
const searches: string[] = [];
@@ -311,6 +461,53 @@ describe("AccountOperationsPage", () => {
});
});
it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth();
const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request }) => {
requested.push(Object.fromEntries(new URL(request.url).searchParams));
return HttpResponse.json(
resource(null, {
sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" },
}),
);
},
),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
);
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
const retry = await screen.findByRole("button", { name: "Повторить" });
expect(requested).toEqual([
{
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: "trade",
limit: "100",
},
]);
expect(syncRequests).toEqual([]);
fireEvent.click(retry);
await waitFor(() =>
expect(syncRequests).toEqual([
{
screen: "operations",
accountId: "account-1",
from: "2024-02-29T00:00:00.000Z",
to: "2024-03-01T23:59:59.999Z",
categories: ["trade"],
limit: 100,
},
]),
);
});
it("does not label incomplete empty operations as a confirmed empty period", async () => {
configureTestAuth();
server.use(
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { z } from "zod";
import {
type BrokerageOperation,
brokerageOperationsQueryOptions,
@@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api";
type AccountOperationsPageViewModel =
| { state: "loading" }
| { state: "error"; retry: () => void }
| { state: "error"; retry: () => void; onReset: () => void }
| {
state: "loaded";
sourceError?: boolean;
@@ -47,8 +48,11 @@ const useAccountOperationsPageVm = (
const accountId = scopeId ?? routeAccountId;
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const appliedFrom = searchParams.get("from");
const appliedTo = searchParams.get("to");
const urlFrom = searchParams.get("from");
const urlTo = searchParams.get("to");
const validUrlPeriod = isValidPeriod(urlFrom, urlTo);
const appliedFrom = validUrlPeriod ? urlFrom : null;
const appliedTo = validUrlPeriod ? urlTo : null;
const appliedCategories = operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
);
@@ -58,8 +62,8 @@ const useAccountOperationsPageVm = (
useState<OperationCategory[]>(appliedCategories);
const scopeKey = JSON.stringify([
accountId,
appliedFrom,
appliedTo,
urlFrom,
urlTo,
appliedCategories,
]);
const [pagination, setPagination] = useState<{
@@ -69,14 +73,26 @@ const useAccountOperationsPageVm = (
const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : [];
const cursor = cursors.at(-1);
useEffect(() => {
setDraftFrom(searchParams.get("from"));
setDraftTo(searchParams.get("to"));
setPagination((current) =>
current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] },
);
}, [scopeKey]);
useEffect(() => {
if (validUrlPeriod) return;
const normalized = new URLSearchParams(searchParams);
normalized.delete("from");
normalized.delete("to");
setSearchParams(normalized, { replace: true });
}, [validUrlPeriod, searchParams, setSearchParams]);
useEffect(() => {
setDraftFrom(appliedFrom);
setDraftTo(appliedTo);
setDraftCategories(
operationCategories.filter((category) =>
(searchParams.get("categories") ?? "").split(",").includes(category),
),
);
}, [searchParams]);
}, [appliedFrom, appliedTo, searchParams]);
const firstPageQuery = {
from: dateStart(appliedFrom),
to: dateEnd(appliedTo),
@@ -104,7 +120,15 @@ const useAccountOperationsPageVm = (
exact: true,
});
resetCursors();
} else void sync.refresh();
} else void sync.retry();
};
const onReset = () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
};
if (operations.isLoading) return { state: "loading" };
@@ -113,7 +137,7 @@ const useAccountOperationsPageVm = (
(!operations.data ||
apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR")
)
return { state: "error", retry };
return { state: "error", retry, onReset };
if (!operations.data) return { state: "loading" };
const nextCursor = operations.data.nextCursor;
@@ -139,11 +163,12 @@ const useAccountOperationsPageVm = (
items: operations.data.items,
hasNext: Boolean(operations.data.hasNext && nextCursor),
hasPrevious: Boolean(cursors.length),
canApply: isDirty,
canApply: isDirty && isValidPeriod(draftFrom, draftTo),
onDraftFromChange: setDraftFrom,
onDraftToChange: setDraftTo,
onDraftCategoriesChange: setDraftCategories,
onApply: () => {
if (!isValidPeriod(draftFrom, draftTo)) return;
setSearchParams({
...(draftFrom ? { from: draftFrom } : {}),
...(draftTo ? { to: draftTo } : {}),
@@ -153,14 +178,7 @@ const useAccountOperationsPageVm = (
});
resetCursors();
},
onReset: () => {
setDraftFrom(null);
setDraftTo(null);
setDraftCategories([]);
setSearchParams({});
resetCursors();
void queryClient.invalidateQueries({ queryKey: resetQueryKey });
},
onReset,
onNext: () => {
if (nextCursor)
setPagination({ scopeKey, cursors: [...cursors, nextCursor] });
@@ -170,6 +188,16 @@ const useAccountOperationsPageVm = (
};
};
const calendarDate = z.string().date();
function isValidPeriod(from: string | null, to: string | null) {
return (
[from, to].every(
(date) => date === null || calendarDate.safeParse(date).success,
) &&
(!from || !to || from <= to)
);
}
function dateStart(value: string | null) {
return value ? `${value}T00:00:00.000Z` : undefined;
}
@@ -1,4 +1,4 @@
import { Button } from "@mantine/core";
import { Button, Group } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import type { FC } from "react";
import { useTranslation } from "react-i18next";
@@ -20,7 +20,14 @@ const AccountOperationsPageError: FC<AccountOperationsPageErrorProps> = (
error
title={t("ledger.errorTitle")}
text={t("ledger.errorText")}
action={<Button onClick={retry}>{t("ledger.retry")}</Button>}
action={
<Group>
<Button onClick={retry}>{t("ledger.retry")}</Button>
<Button variant="default" onClick={props.onReset}>
{t("ledger.reset")}
</Button>
</Group>
}
/>
);
};
@@ -660,26 +660,6 @@ describe("AccountPage states", () => {
).toBeInTheDocument();
});
it("renders the bonds section as a single-column grid on any screen width", () => {
renderPage(
<AccountPageLoaded
{...loadedProps({
...portfolio,
positions: [portfolio.positions[1]],
})}
/>,
);
const singleColumnGrids = Array.from(document.querySelectorAll("style"))
.map((style) => (style.textContent ?? "").replace(/\s/g, ""))
.filter(
(css) =>
css.includes("--sg-cols:1") &&
!css.includes("@media") &&
!/--sg-cols:[23]/.test(css),
);
expect(singleColumnGrids).toHaveLength(1);
});
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
@@ -131,7 +131,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
return {
state: "error" as const,
code: apiErrorCode(portfolio.error),
retry: sync.refresh,
retry: sync.retry,
};
return { state: "loading" as const };
}
@@ -140,7 +140,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel {
refreshing: portfolio.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: portfolio.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
if (
portfolio.meta?.availability === "complete" &&
@@ -0,0 +1,76 @@
import "@mantine/core/styles.css";
import "@wealthledger/ui/styles.css";
import "@/shared/i18n";
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { afterEach, expect, it } from "vitest";
import type {
AccountPagePosition,
AccountPagePositionSection,
} from "./AccountPage.vm";
import { PositionSection } from "./components/PositionSection.view";
afterEach(cleanup);
it("keeps expanded positions vertical while other positions adapt to the viewport", () => {
const kinds: AccountPagePositionSection["kind"][] = [
"stocks",
"bonds",
"other",
];
const width = window.innerWidth;
expect([375, 1280]).toContain(width);
for (const kind of kinds) {
const positions: AccountPagePosition[] = [1, 2, 3].map((index) => ({
figi: null,
displayName: `Инструмент ${index}`,
type: kind === "stocks" ? "STOCK" : kind === "bonds" ? "BOND" : "FUND",
quantity: "10",
currentValue: { currency: "RUB", value: "1000" },
averagePurchasePrice: null,
absoluteReturn: null,
isin: null,
ticker: null,
logoName: null,
shareText: null,
returnText: null,
logoSrc: null,
}));
const { unmount } = render(
<MantineProvider
theme={theme}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme="light"
>
<PositionSection
section={{
kind,
groups: positions.map((position) => ({
key: position.displayName,
positions: [position],
})),
}}
/>
</MantineProvider>,
);
const bounds = positions.map((position) =>
screen.getByText(position.displayName).getBoundingClientRect(),
);
const context = `${kind} at ${width}px`;
for (const [index, current] of bounds.entries()) {
expect(current.width, context).toBeGreaterThan(0);
expect(current.height, context).toBeGreaterThan(0);
if (index === 0) continue;
const previous = bounds[index - 1];
if (kind === "other" && width === 1280) {
expect(current.top, context).toBeCloseTo(previous.top, 0);
expect(current.left, context).toBeGreaterThan(previous.right);
} else {
expect(current.left, context).toBeCloseTo(previous.left, 0);
expect(current.top, context).toBeGreaterThan(previous.bottom);
}
}
unmount();
}
});
@@ -1,6 +1,6 @@
import { MantineProvider } from "@mantine/core";
import { type QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
@@ -43,6 +43,21 @@ function renderPage(queryClient: QueryClient) {
}
describe("AccountsPage", () => {
it("loads accounts after retrying an initial HTTP failure", async () => {
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([account]));
}),
);
renderPage(createTestQueryClient());
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
expect(await screen.findByText(account.name)).toBeVisible();
expect(modes).toEqual([null, "observe"]);
});
it.each([
["loading", AccountsPageLoading, "status"],
["empty", AccountsPageEmpty, undefined],
@@ -143,8 +158,10 @@ describe("AccountsPage", () => {
expect(screen.getByText(account.name)).toBeInTheDocument();
screen.getByRole("button", { name: "Повторить" }).click();
await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }]));
expect(requestCount).toBe(2);
await waitFor(() => expect(requestCount).toBe(3));
await waitFor(() => expect(warning).not.toBeInTheDocument());
expect(screen.getByText(account.name)).toBeVisible();
expect(requests).toEqual([]);
});
it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -18,7 +18,7 @@ function useAccountsPageViewModel() {
return {
state: "error" as const,
code: apiErrorCode(accounts.error),
retry: sync.refresh,
retry: sync.retry,
};
}
if (!accounts.data?.length)
@@ -28,7 +28,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
return {
state: "loaded" as const,
@@ -37,7 +37,7 @@ function useAccountsPageViewModel() {
refreshing: accounts.isFetching || sync.refreshing,
sourceError: sync.sourceError,
refreshError: accounts.isError || sync.transportError || sync.sourceError,
retry: sync.refresh,
retry: sync.retry,
};
}
+17 -49
View File
@@ -1,5 +1,3 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
type BondPeriod,
@@ -10,12 +8,11 @@ import {
useBondPosition,
useBondQuote,
} from "@/entities/bond";
import type { OperationCategory } from "@/entities/broker-operation";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import { section } from "@/features/instrument-details";
section,
useInstrumentOperations,
} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -38,27 +35,13 @@ function useBondPageVm() {
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
filters.find((value) => value === params.get("events")) ?? "all";
const scope = `${uid}:${filter}`;
const [pagination, setPagination] = useState<{
scope: string;
cursors: string[];
}>({ scope, cursors: [] });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const quote = useBondQuote(uid);
const metrics = useBondMetrics(uid);
const candles = useBondCandles(uid, period);
const position = useBondPosition(uid);
const coupons = useBondCoupons(uid);
const firstPage = {
instrumentUid: uid,
categories: categories[filter],
limit: 20,
};
const operations = useBrokerageOperations(uid ? "all" : undefined, {
...firstPage,
cursor: cursors.at(-1),
});
const history = useInstrumentOperations(uid, filter, categories[filter]);
const { operations } = history;
const sync = useScreenSync(
{
screen: "bond",
@@ -75,15 +58,6 @@ function useBondPageVm() {
next.set(key, value);
setParams(next, { replace: true });
};
const retryOperations = () => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
setPagination({ scope, cursors: [] });
void client.invalidateQueries({
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void sync.refresh();
};
const chartData =
candles.data?.candles.map((candle) => ({
...candle,
@@ -99,26 +73,26 @@ function useBondPageVm() {
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
bond: bond.data,
bondState: section(bond, uid ? sync.refresh : () => bond.refetch()),
bondState: section(bond, uid ? sync.retry : () => bond.refetch()),
code: apiErrorCode(bond.error),
logoSrc: instrumentLogoUrl(bond.data?.logoName),
metrics: metrics.data,
metricsState: section(metrics, sync.refresh),
metricsState: section(metrics, sync.retry),
quote: quote.data,
quoteState: section(quote, sync.refresh),
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine: null,
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.refresh),
positionState: section(position, sync.retry),
coupons: coupons.data,
couponsState: section(coupons, sync.refresh),
couponsState: section(coupons, sync.retry),
upcoming:
coupons.data?.coupons
.filter((event) => event.timing === "upcoming")
@@ -137,8 +111,8 @@ function useBondPageVm() {
) ?? [],
operations: operations.data,
operationsState: {
...section(operations, sync.refresh),
retry: retryOperations,
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
eventRows:
operations.data?.items.map((item) => ({
@@ -148,15 +122,9 @@ function useBondPageVm() {
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: cursors.length > 0,
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
scope,
cursors: [...cursors, operations.data.nextCursor],
});
},
hasPrevious: history.hasPrevious,
onPrevious: history.onPrevious,
onNext: history.onNext,
};
}
type BondPageVm = ReturnType<typeof useBondPageVm>;
+1 -1
View File
@@ -28,7 +28,7 @@ function useBondsPageVm() {
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void sync.refresh();
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
+24 -1
View File
@@ -46,6 +46,7 @@ const operation = {
};
function setup(
options: {
month?: string;
missing?: boolean;
summaryError?: boolean;
cardsError?: boolean;
@@ -101,7 +102,9 @@ function setup(
render(
<QueryClientProvider client={client}>
<MantineProvider env="test">
<MemoryRouter initialEntries={["/cards/a?month=2026-08"]}>
<MemoryRouter
initialEntries={[`/cards/a?month=${options.month ?? "2026-08"}`]}
>
<Routes>
<Route path="/cards/:cardId" element={<CardPage />} />
</Routes>
@@ -112,6 +115,26 @@ function setup(
return { requests, summaryRequests, client };
}
describe("CardPage", () => {
it.each([
["0001-01", "0001-01-31"],
["0099-12", "0099-12-31"],
["2026-09", "2026-09-30"],
["2024-02", "2024-02-29"],
])(
"sends the same legacy month %s range to history and summary",
async (month, to) => {
const { requests, summaryRequests } = setup({ month });
await screen.findByText("Перевод на «Запасная»");
await waitFor(() => expect(summaryRequests).toHaveLength(1));
for (const request of [requests[0], summaryRequests[0]]) {
const query = new URLSearchParams(request);
expect(query.get("from")).toBe(`${month}-01`);
expect(query.get("to")).toBe(to);
expect(query.has("month")).toBe(false);
}
},
);
it("shares date range with history, pages the journal and keeps current balance", async () => {
const { requests, summaryRequests } = setup();
await screen.findByRole("heading", { name: "Повседневная" });
+132 -3
View File
@@ -9,6 +9,7 @@ import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { StockPage } from "./StockPage";
import type { StockPageVm } from "./StockPage.vm";
const LocationProbe = () => {
const location = useLocation();
@@ -69,14 +70,18 @@ function setup(
noPosition?: boolean;
failCompany?: boolean;
failStock?: boolean;
cursorError?: string;
path?: string;
slug?: string;
ambiguousTicker?: boolean;
metrics?: NonNullable<StockPageVm["company"]>["metrics"];
} = {},
) {
const requests: URL[] = [];
const resolved = { ...stock, slug: options.slug ?? uid };
let failQuote = false;
let cursorError = options.cursorError;
let operationPayment = "200";
server.use(
http.get("*/api/v1/stocks/:uid", () =>
options.failStock
@@ -118,7 +123,7 @@ function setup(
: HttpResponse.json(
resource({
description: "Описание компании",
metrics: [
metrics: options.metrics ?? [
{
key: "peRatioTtm",
value: 5.6,
@@ -164,6 +169,8 @@ function setup(
const url = new URL(request.url);
requests.push(url);
const next = url.searchParams.has("cursor");
if (next && cursorError)
return HttpResponse.json({ code: cursorError }, { status: 400 });
return HttpResponse.json(
resource({
accountId: "all",
@@ -180,7 +187,7 @@ function setup(
category: next ? "trade" : "income",
description: "",
account: { id: "main", name: "Основной" },
payment: money("200"),
payment: money(operationPayment),
quantity: next ? "2" : null,
price: next ? money("100") : null,
},
@@ -209,12 +216,73 @@ function setup(
);
return {
requests,
recoverOperations: () => {
cursorError = undefined;
operationPayment = "201";
},
failQuote: () => {
failQuote = true;
},
};
}
describe("stock page", () => {
it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => {
setup({
metrics: [
{
key: "marketCapitalization",
value: 1234567.89,
unit: "money",
currency: null,
period: null,
},
{
key: "revenueTtm",
value: 1234567.89,
unit: "money",
currency: "RUB",
period: "TTM",
},
{
key: "ebitdaTtm",
value: 1234567.89,
unit: "money",
currency: "USD",
period: "TTM",
},
{
key: "netIncomeTtm",
value: 1234567.89,
unit: "money",
currency: "CAD",
period: "TTM",
},
{
key: "peRatioTtm",
value: 5.678,
unit: "ratio",
currency: null,
period: "TTM",
},
{
key: "roe",
value: 12.3456,
unit: "percent",
currency: null,
period: null,
},
],
});
expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible();
for (const value of [
"1,23 млн ₽",
"1,23 млн USD",
"1,23 млн CAD",
"5,68 ×",
"12,35 %",
])
expect(screen.getByText(value)).toBeVisible();
});
it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => {
const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" });
await screen.findByText("Зачисление дивидендов");
@@ -290,7 +358,32 @@ describe("stock page", () => {
),
).toHaveAttribute("aria-live", "polite");
});
it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => {
it("recovers an initially missing company section without reloading successful sections", async () => {
const { requests } = setup({ failCompany: true });
const retry = await screen.findByRole("button", { name: "Повторить" });
await screen.findByText("Зачисление дивидендов");
const modes: (string | null)[] = [];
server.use(
http.get("*/api/v1/stocks/:uid/company", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource({
description: "Восстановленное описание",
metrics: [],
unavailable: [],
observedAt: quote.observedAt,
}),
);
}),
);
const previousRequests = requests.length;
fireEvent.click(retry);
expect(await screen.findByText("Восстановленное описание")).toBeVisible();
expect(screen.getByText("Зачисление дивидендов")).toBeVisible();
expect(requests).toHaveLength(previousRequests);
expect(modes).toEqual(["observe"]);
});
it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => {
const { requests } = setup();
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
@@ -315,6 +408,14 @@ describe("stock page", () => {
),
).toBe(true),
);
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
fireEvent.click(screen.getByRole("radio", { name: "Все" }));
expect(await screen.findByText("Продажа")).toBeVisible();
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Назад" }));
expect(await screen.findByText("Зачисление дивидендов")).toBeVisible();
expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
await waitFor(() =>
expect(
@@ -322,6 +423,34 @@ describe("stock page", () => {
).toBe(true),
);
});
it.each([
["INVALID_OPERATIONS_CURSOR", null, null],
["TBANK_UNAVAILABLE", "next", "observe"],
])(
"recovers history after %s with fresh data and the correct cursor",
async (code, cursor, sync) => {
const controls = setup({ cursorError: code });
await screen.findByText("Зачисление дивидендов");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
const retry = await screen.findByRole("button", { name: "Повторить" });
controls.recoverOperations();
const previousRequests = controls.requests.length;
fireEvent.click(retry);
expect(await screen.findByText("+201 ₽")).toBeVisible();
const recovered = controls.requests.slice(previousRequests);
expect(recovered).toHaveLength(1);
expect(recovered[0]?.pathname).toBe(
"/api/v1/brokerage-accounts/all/operations",
);
expect(recovered[0]?.searchParams.get("instrumentUid")).toBe(uid);
expect(recovered[0]?.searchParams.get("limit")).toBe("20");
expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor);
expect(recovered[0]?.searchParams.get("sync")).toBe(sync);
if (cursor)
expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled();
else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled();
},
);
it("keeps the last quote on a refresh failure", async () => {
const scopes: unknown[] = [];
server.use(
@@ -190,13 +190,9 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metric.unit === "money"
? new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: metric.currency ?? "RUB",
currencyDisplay: "narrowSymbol",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value
? metric.currency === "RUB"
? "₽"
: (metric.currency ?? t("stock.currencyUnknown"))
: metric.unit === "percent"
? "%"
: "×"}
+18 -50
View File
@@ -1,11 +1,5 @@
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useParams, useSearchParams } from "react-router";
import {
brokerageOperationsQueryOptions,
type OperationCategory,
useBrokerageOperations,
} from "@/entities/broker-operation";
import type { OperationCategory } from "@/entities/broker-operation";
import {
type StockPeriod,
useStock,
@@ -15,7 +9,10 @@ import {
useStockPosition,
useStockQuote,
} from "@/entities/stock";
import { section } from "@/features/instrument-details";
import {
section,
useInstrumentOperations,
} from "@/features/instrument-details";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
@@ -38,27 +35,13 @@ function useStockPageVm() {
periods.find((value) => value === params.get("period")) ?? "month";
const filter =
filters.find((value) => value === params.get("events")) ?? "all";
const scope = `${uid}:${filter}`;
const [pagination, setPagination] = useState<{
scope: string;
cursors: string[];
}>({ scope, cursors: [] });
const cursors = pagination.scope === scope ? pagination.cursors : [];
const client = useQueryClient();
const quote = useStockQuote(uid);
const candles = useStockCandles(uid, period);
const position = useStockPosition(uid);
const company = useStockCompany(uid);
const dividends = useStockDividends(uid);
const firstPage = {
instrumentUid: uid,
categories: categories[filter],
limit: 20,
};
const operations = useBrokerageOperations(uid ? "all" : undefined, {
...firstPage,
cursor: cursors.at(-1),
});
const history = useInstrumentOperations(uid, filter, categories[filter]);
const { operations } = history;
const sync = useScreenSync(
{
screen: "stock",
@@ -75,15 +58,6 @@ function useStockPageVm() {
next.set(key, value);
setParams(next, { replace: true });
};
const retryOperations = () => {
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
setPagination({ scope, cursors: [] });
void client.invalidateQueries({
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
exact: true,
});
} else void sync.refresh();
};
const chartData =
candles.data?.candles.map((candle) => ({
...candle,
@@ -105,15 +79,15 @@ function useStockPageVm() {
? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}`
: null,
stock: stock.data,
stockState: section(stock, uid ? sync.refresh : () => stock.refetch()),
stockState: section(stock, uid ? sync.retry : () => stock.refetch()),
code: apiErrorCode(stock.error),
logoSrc: instrumentLogoUrl(stock.data?.logoName),
quote: quote.data,
quoteState: section(quote, sync.refresh),
quoteState: section(quote, sync.retry),
candles: candles.data,
chartData,
priceRows: chartData.toReversed(),
candlesState: section(candles, sync.refresh),
candlesState: section(candles, sync.retry),
period,
setPeriod: (value: string) => setParam("period", value),
averageLine:
@@ -121,11 +95,11 @@ function useStockPageVm() {
hasIncompleteCandle:
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
position: position.data,
positionState: section(position, sync.refresh),
positionState: section(position, sync.retry),
company: companyData,
companyState: section(company, sync.refresh),
companyState: section(company, sync.retry),
dividends: dividends.data,
dividendsState: section(dividends, sync.refresh),
dividendsState: section(dividends, sync.retry),
upcoming:
dividends.data?.dividends
.filter((event) => event.timing === "upcoming")
@@ -144,8 +118,8 @@ function useStockPageVm() {
) ?? [],
operations: operations.data,
operationsState: {
...section(operations, sync.refresh),
retry: retryOperations,
...section(operations, sync.retry),
retry: () => history.retry(sync.retry),
},
eventRows:
operations.data?.items.map((item) => ({
@@ -155,15 +129,9 @@ function useStockPageVm() {
})) ?? [],
filter,
setFilter: (value: string) => setParam("events", value),
hasPrevious: cursors.length > 0,
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
scope,
cursors: [...cursors, operations.data.nextCursor],
});
},
hasPrevious: history.hasPrevious,
onPrevious: history.onPrevious,
onNext: history.onNext,
};
}
type StockPageVm = ReturnType<typeof useStockPageVm>;
@@ -28,7 +28,7 @@ function useStocksPageVm() {
partial: result.meta?.availability === "partial",
code: apiErrorCode(result.error),
retry: () => {
void sync.refresh();
void sync.retry();
},
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
items:
+96 -15
View File
@@ -1124,9 +1124,41 @@ export interface components {
instruments: components["schemas"]["IncomeInstrumentDto"][];
entries: components["schemas"]["IncomeAnalyticsEntryDto"][];
};
IncomeDependencyDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
/** @enum {string} */
role: "selected" | "previous" | "year";
};
IncomeAnalyticsMetaDto: {
dataRevision: string;
stateVersion: string;
unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string;
/** @enum {string} */
availability: "missing" | "partial" | "complete";
/** @enum {string} */
freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */
lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"];
/** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */
dependencies: components["schemas"]["IncomeDependencyDto"][];
};
IncomeAnalyticsResourceDto: {
data: components["schemas"]["IncomeAnalyticsDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"];
meta: components["schemas"]["IncomeAnalyticsMetaDto"];
};
UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
@@ -1515,20 +1547,50 @@ export interface components {
PutTbankConnectionDto: {
token: string;
};
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
EventsInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "events";
/**
* Format: date
* @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.
*/
from?: string;
/**
* Format: date
* @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.
*/
to?: string;
};
SyncCandleRangeDto: {
/** Format: date-time */
from: string;
/** Format: date-time */
to: string;
};
InvestmentScreenDto: {
/** @enum {string} */
screen: "accounts" | "account" | "operations" | "income" | "events" | "stocks" | "bonds" | "stock" | "bond";
TimestampInvestmentScreenDto: {
accountId?: string;
/** Format: date-time */
from?: string;
/** Format: date-time */
to?: string;
/** Format: uuid */
instrumentUid?: string;
query?: string;
@@ -1537,11 +1599,26 @@ export interface components {
candleRange?: components["schemas"]["SyncCandleRangeDto"];
categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[];
limit?: number;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond";
/**
* Format: date-time
* @description Start instant for operations.
*/
from?: string;
/**
* Format: date-time
* @description End instant for operations.
*/
to?: string;
};
TbankSyncAcceptedDto: {
/** Format: uuid */
id: string;
scope: components["schemas"]["InvestmentScreenDto"];
scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
/** Format: date-time */
now: string;
};
@@ -1553,7 +1630,7 @@ export interface components {
TbankSyncStatusDto: {
/** Format: uuid */
id: string;
scope: components["schemas"]["InvestmentScreenDto"] | null;
scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null;
/** Format: date-time */
now: string;
/** @enum {string} */
@@ -3525,7 +3602,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
to?: string;
};
header?: never;
@@ -3661,7 +3740,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */
from?: string;
/** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */
to?: string;
};
header?: never;
@@ -3731,9 +3812,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
@@ -3801,9 +3882,9 @@ export interface operations {
parameters: {
query?: {
sync?: "observe";
/** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */
/** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */
from?: string;
/** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */
/** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */
to?: string;
};
header?: never;
@@ -5160,7 +5241,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["InvestmentScreenDto"];
"application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
};
};
responses: {
+23 -13
View File
@@ -8,6 +8,7 @@ import {
} from "@tanstack/react-query";
import { useEffect } from "react";
import { useAuthenticatedQuery } from "./authenticated-query";
import { getAuthenticatedSessionSnapshot } from "./client";
import type { components } from "./generated/schema";
import { apiErrorCode } from "./problem-details";
@@ -21,6 +22,12 @@ function unwrapResourceResult<T extends Resource, E>(
result: UseQueryResult<T, E>,
) {
const missing = result.isSuccess && result.data.data === null;
const data: T["data"] | undefined = result.data?.data;
const meta: T["meta"] | undefined = result.data?.meta;
const status: UseQueryResult<T, E>["status"] = missing
? "pending"
: result.status;
// Project resource fields; refetch still returns the transport envelope.
return {
...result,
transport: {
@@ -28,18 +35,11 @@ function unwrapResourceResult<T extends Resource, E>(
fetchStatus: result.fetchStatus,
error: result.error,
},
data: result.data?.data ?? undefined,
meta: result.data?.meta,
data: data ?? undefined,
meta,
isPending: result.isPending || missing,
isSuccess: result.isSuccess && !missing,
status: missing ? "pending" : result.status,
} as UseQueryResult<NonNullable<T["data"]>, E> & {
meta?: T["meta"];
transport: {
status: UseQueryResult<T, E>["status"];
fetchStatus: UseQueryResult<T, E>["fetchStatus"];
error: E | null;
};
status,
};
}
@@ -107,8 +107,14 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
}, [client, group, query.error]);
const observe = async (expectedStateVersion?: string) => {
const queryFn = guarded;
if (typeof queryFn !== "function") return;
await client.cancelQueries({ queryKey: options.queryKey, exact: true });
if (
!query.isEnabled ||
!getAuthenticatedSessionSnapshot() ||
typeof queryFn !== "function"
)
return;
// Cancellation is synchronous; start the replacement in the same boundary.
void client.cancelQueries({ queryKey: options.queryKey, exact: true });
return client.fetchQuery({
...options,
staleTime: 0,
@@ -144,7 +150,11 @@ function useResourceQuery<T extends Resource, E, K extends QueryKey>(
},
});
};
return { ...unwrapResourceResult(query), observe };
return {
...unwrapResourceResult(query),
queryKey: options.queryKey,
observe,
};
}
export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery };
+285 -35
View File
@@ -2668,6 +2668,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2677,6 +2678,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2878,6 +2880,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2887,6 +2890,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.",
"schema": {
"format": "date",
"type": "string"
@@ -2984,7 +2988,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
"schema": {
"format": "date",
"type": "string"
@@ -2994,7 +2998,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
"schema": {
"format": "date",
"type": "string"
@@ -3100,7 +3104,7 @@
"name": "from",
"required": false,
"in": "query",
"description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.",
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.",
"schema": {
"format": "date",
"type": "string"
@@ -3110,7 +3114,7 @@
"name": "to",
"required": false,
"in": "query",
"description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.",
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.",
"schema": {
"format": "date",
"type": "string"
@@ -5001,7 +5005,31 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvestmentScreenDto"
"oneOf": [
{
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
}
}
}
}
@@ -6878,6 +6906,129 @@
"entries"
]
},
"IncomeDependencyDto": {
"type": "object",
"properties": {
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
"coverage": {
"$ref": "#/components/schemas/InvestmentCoverageDto"
},
"resourceKey": {
"type": "string"
},
"availability": {
"type": "string",
"enum": [
"missing",
"partial",
"complete"
]
},
"freshness": {
"type": "string",
"enum": [
"unknown",
"fresh",
"stale"
]
},
"lastSuccessfulAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"sync": {
"$ref": "#/components/schemas/InvestmentSyncDto"
},
"role": {
"type": "string",
"enum": [
"selected",
"previous",
"year"
]
}
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
"lastSuccessfulAt",
"sync",
"role"
]
},
"IncomeAnalyticsMetaDto": {
"type": "object",
"properties": {
"dataRevision": {
"type": "string"
},
"stateVersion": {
"type": "string"
},
"unavailableReason": {
"type": "string"
},
"coverage": {
"$ref": "#/components/schemas/InvestmentCoverageDto"
},
"resourceKey": {
"type": "string"
},
"availability": {
"type": "string",
"enum": [
"missing",
"partial",
"complete"
]
},
"freshness": {
"type": "string",
"enum": [
"unknown",
"fresh",
"stale"
]
},
"lastSuccessfulAt": {
"type": "string",
"format": "date-time",
"nullable": true
},
"sync": {
"$ref": "#/components/schemas/InvestmentSyncDto"
},
"dependencies": {
"description": "Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.",
"type": "array",
"items": {
"$ref": "#/components/schemas/IncomeDependencyDto"
}
}
},
"required": [
"dataRevision",
"stateVersion",
"resourceKey",
"availability",
"freshness",
"lastSuccessfulAt",
"sync",
"dependencies"
]
},
"IncomeAnalyticsResourceDto": {
"type": "object",
"properties": {
@@ -6891,7 +7042,7 @@
]
},
"meta": {
"$ref": "#/components/schemas/InvestmentResourceMetaDto"
"$ref": "#/components/schemas/IncomeAnalyticsMetaDto"
}
},
"required": [
@@ -8677,6 +8828,62 @@
"token"
]
},
"IncomeInvestmentScreenDto": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"screen": {
"type": "string",
"enum": [
"income"
]
},
"from": {
"type": "string",
"format": "date",
"description": "Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month."
},
"to": {
"type": "string",
"format": "date",
"description": "Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now."
}
},
"required": [
"accountId",
"screen"
]
},
"EventsInvestmentScreenDto": {
"type": "object",
"properties": {
"accountId": {
"type": "string"
},
"screen": {
"type": "string",
"enum": [
"events"
]
},
"from": {
"type": "string",
"format": "date",
"description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC."
},
"to": {
"type": "string",
"format": "date",
"description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end."
}
},
"required": [
"accountId",
"screen"
]
},
"SyncCandleRangeDto": {
"type": "object",
"properties": {
@@ -8694,34 +8901,12 @@
"to"
]
},
"InvestmentScreenDto": {
"TimestampInvestmentScreenDto": {
"type": "object",
"properties": {
"screen": {
"type": "string",
"enum": [
"accounts",
"account",
"operations",
"income",
"events",
"stocks",
"bonds",
"stock",
"bond"
]
},
"accountId": {
"type": "string"
},
"from": {
"type": "string",
"format": "date-time"
},
"to": {
"type": "string",
"format": "date-time"
},
"instrumentUid": {
"type": "string",
"format": "uuid"
@@ -8759,6 +8944,28 @@
"type": "number",
"minimum": 1,
"maximum": 1000
},
"screen": {
"type": "string",
"enum": [
"accounts",
"account",
"operations",
"stocks",
"bonds",
"stock",
"bond"
]
},
"from": {
"type": "string",
"format": "date-time",
"description": "Start instant for operations."
},
"to": {
"type": "string",
"format": "date-time",
"description": "End instant for operations."
}
},
"required": [
@@ -8773,7 +8980,31 @@
"format": "uuid"
},
"scope": {
"$ref": "#/components/schemas/InvestmentScreenDto"
"oneOf": [
{
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
}
},
"now": {
"type": "string",
@@ -8813,13 +9044,32 @@
"format": "uuid"
},
"scope": {
"nullable": true,
"type": "object",
"allOf": [
"oneOf": [
{
"$ref": "#/components/schemas/InvestmentScreenDto"
"$ref": "#/components/schemas/IncomeInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/EventsInvestmentScreenDto"
},
{
"$ref": "#/components/schemas/TimestampInvestmentScreenDto"
}
]
],
"discriminator": {
"propertyName": "screen",
"mapping": {
"income": "#/components/schemas/IncomeInvestmentScreenDto",
"events": "#/components/schemas/EventsInvestmentScreenDto",
"accounts": "#/components/schemas/TimestampInvestmentScreenDto",
"account": "#/components/schemas/TimestampInvestmentScreenDto",
"operations": "#/components/schemas/TimestampInvestmentScreenDto",
"stocks": "#/components/schemas/TimestampInvestmentScreenDto",
"bonds": "#/components/schemas/TimestampInvestmentScreenDto",
"stock": "#/components/schemas/TimestampInvestmentScreenDto",
"bond": "#/components/schemas/TimestampInvestmentScreenDto"
}
},
"nullable": true
},
"now": {
"type": "string",
+7
View File
@@ -269,6 +269,7 @@ const ru = {
},
},
stock: {
currencyUnknown: "валюта неизвестна",
searchTitle: "Акции",
searchSubtitle:
"Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.",
@@ -636,6 +637,7 @@ const ru = {
comparisonPending: "Период сравнения ещё не загружен полностью.",
comparisonFailed:
"Не удалось загрузить период сравнения. Повторите обновление.",
previousUnavailable: "Сравнение с предыдущим периодом недоступно.",
yearUnavailable: "Сравнение с годом ранее недоступно.",
difference: "Разница",
total: "Итого",
@@ -659,6 +661,10 @@ const ru = {
},
settings: { title: "Настройки" },
tbank: {
close: "Закрыть",
refreshError:
"Не удалось обновить статус подключения. Показаны последние полученные данные.",
refreshing: "Обновляем статус подключения…",
title: "T-Bank Invest",
disconnected: "Не подключено",
connected: "Подключено с {{date}}",
@@ -683,6 +689,7 @@ const ru = {
title: "Не удалось открыть страницу",
text: "Перейдите в безопасный раздел и попробуйте снова.",
action: "Продолжить",
reload: "Перезагрузить страницу",
},
states: {
loadingTitle: "Загрузка",
+27
View File
@@ -177,6 +177,33 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
export const Colors: Story = { name: "Цвета", render: () => <ColorsView /> };
export const SecondaryText: Story = {
name: "Вторичный текст",
render: () => (
<SimpleGrid cols={{ base: 1, sm: 2 }}>
{[
"var(--wl-color-surface)",
"var(--wl-color-canvas)",
"var(--wl-color-subtle)",
...Object.keys(designTokens.color.palettes)
.filter((palette) => palette !== "dark")
.flatMap((palette) => [
`var(--mantine-color-${palette}-0)`,
`color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`,
]),
].map((background) => (
<Paper key={background} p="md" withBorder style={{ background }}>
<Text size="sm" c="dimmed">
22.09.2026 · Синтетические данные
</Text>
<Text size="xs" c="dimmed">
Не удалось обновить данные. Сохранён предыдущий результат.
</Text>
</Paper>
))}
</SimpleGrid>
),
};
export const Typography: Story = {
name: "Типографика",
render: () => <TypographyView />,
+8 -3
View File
@@ -9,16 +9,21 @@ function luminance(color: string) {
);
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
}
describe("financial text contrast", () => {
it.each(["positive", "negative"] as const)(
"keeps %s readable on journal, position and tinted card surfaces",
describe("semantic text contrast", () => {
it.each(["positive", "negative", "muted"] as const)(
"keeps %s readable on canvas, white, subtle and tinted surfaces",
(tone) => {
const role = tokens.color.roles[tone];
const palettes = tokens.color.palettes;
const foreground =
palettes[role.color as keyof typeof palettes][role.shade];
const surfaces = [
"#ffffff",
palettes.gray[0],
palettes.gray[2],
...Object.entries(palettes)
.filter(([name]) => name !== "dark")
.map(([, palette]) => palette[0]),
...Object.entries(palettes)
.filter(([name]) => name !== "dark")
.map(
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"paths": {
"@/*": ["src/*"],
"@wealthledger/ui": ["packages/ui/src/index.ts"],
"@wealthledger/ui/theme": ["packages/ui/src/theme.ts"],
"@wealthledger/ui/styles.css": ["packages/ui/src/styles.css"],
"@wealthledger/icons": ["packages/icons/src/index.ts"]
}
}
}
+1
View File
@@ -22,6 +22,7 @@
},
"include": [
"vite.config.ts",
"vitest.config.ts",
".storybook/main.ts",
"packages/*/vite.config.ts"
]
+45 -4
View File
@@ -1,8 +1,12 @@
import { fileURLToPath, URL } from "node:url";
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { configDefaults, defineConfig } from "vitest/config";
const CI_TEST_TIMEOUT_MS = 15_000;
const LAYOUT_VIEWPORT_HEIGHT = 900;
const LAYOUT_NARROW_WIDTH = 375;
const LAYOUT_WIDE_WIDTH = 1280;
export default defineConfig({
plugins: [react()],
@@ -10,11 +14,48 @@ export default defineConfig({
test: {
// CI runs the UI scenarios with coverage on a slower runner.
testTimeout: process.env.CI ? CI_TEST_TIMEOUT_MS : undefined,
environment: "jsdom",
exclude: configDefaults.exclude,
setupFiles: "./src/test/setup.ts",
projects: [
{
extends: true,
test: {
name: "unit",
environment: "jsdom",
exclude: [...configDefaults.exclude, "**/*.browser.test.tsx"],
setupFiles: "./src/test/setup.ts",
},
},
{
extends: true,
test: {
name: "browser",
include: ["src/**/*.browser.test.tsx"],
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [
{
browser: "chromium",
name: "browser-narrow",
viewport: {
width: LAYOUT_NARROW_WIDTH,
height: LAYOUT_VIEWPORT_HEIGHT,
},
},
{
browser: "chromium",
name: "browser-wide",
viewport: {
width: LAYOUT_WIDE_WIDTH,
height: LAYOUT_VIEWPORT_HEIGHT,
},
},
],
},
},
},
],
coverage: {
all: true,
include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"],
reporter: ["text", "html", "lcov"],