diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index 8c07414..34b6e01 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -30,7 +30,8 @@ backend checkout и не проверяют backend runtime OpenAPI; измен shape/status/route сохраняет публичный контракт предоставленного snapshot. Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot; -`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files. +`openapi:check` запускает native `--check` того же CLI и падает при устаревших +generated types, не меняя tracked files. ## Auth и invalidation diff --git a/.ai/conventions/engineering.md b/.ai/conventions/engineering.md index d9d197b..49ef707 100644 --- a/.ai/conventions/engineering.md +++ b/.ai/conventions/engineering.md @@ -71,6 +71,7 @@ Peer-конфликт решай совместимыми версиями бе | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. | | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. | | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. | +| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. | Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши проверенные входы, команду, exit code, результат и ограничения. Повторяй @@ -88,4 +89,9 @@ Commits и PR titles — Conventional Commits с parser-recognized Issue referen содержит одну связанную задачу. Исключения release и правила ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo. Прямой push в `main`, force-push и перенос опубликованных тегов не входят в -обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». +обычную работу. PR сливается squash или rebase-merge; merge-коммит не +используется. `main` обновляется только fast-forward из `develop`, поэтому +`origin/main` всегда предок `origin/develop`: +`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix — +обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет. +Перед релизом прочитай местный README, раздел «Релизы». diff --git a/.gitea/workflows/pr-title.yml b/.gitea/workflows/pr-title.yml new file mode 100644 index 0000000..950222e --- /dev/null +++ b/.gitea/workflows/pr-title.yml @@ -0,0 +1,34 @@ +name: Frontend verify + +# Metadata-only: refreshes only the `commits` context after a PR title edit. +# Same workflow name keeps the required context; `security` and `verify` +# statuses of the head revision stay untouched (no skipped overwrite). +on: + pull_request: + types: [edited] + +concurrency: + group: frontend-pr-title-${{ gitea.event.pull_request.number }} + cancel-in-progress: true + +jobs: + commits: + 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" diff --git a/.husky/pre-commit b/.husky/pre-commit index 73788eb..82496ba 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,6 @@ gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $? -npx lint-staged -npm run deps +if [ ! -x node_modules/.bin/lint-staged ]; then + echo "lint-staged is not installed; run npm ci" >&2 + exit 1 +fi +node_modules/.bin/lint-staged diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb6468..b8d2d37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## [1.2.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.1.0...v1.2.0) (2026-10-18) + +### Features + +* unify income payment cards and remove comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/33)) ([#74](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/74)) ([43b0f55](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/43b0f5574a79e26de0abae35de16d8aa8700f718)) + +### Bug Fixes + +* align password validation with backend ASCII policy (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#89](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/89)) ([ebe9268](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ebe92684a74b4e2617b04ef00d9406b8a0f674ea)) +* **auth,bank:** guard switch-auth nav and surface banking retry ([e38ecd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e38ecd87d6f69968bb56e6035ceeb6cfda208a3f)), closes [#56](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/56) [#57](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/57) +* **bank:** break BankFeedback's shared/api edge for stories ([#80](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/80)) ([2336804](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/233680448f97dc3ec57d7665ec14c68ac92410e5)) +* deliver screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#85](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/85)) ([ed63ada](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed63adad94f5d0e20f7d0d1446a235f953d10ec2)) +* **events:** seven-date default period and picker from today (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/63)) ([612ab62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/612ab6250979bf8d9b57f4feb6e1476ed3d759e4)) +* respect query eligibility in screen sync (WealthLedger/frontend[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([f295dea](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f295dea6a6a33c597c753b52aff629d5382c963a)) + +### Refactoring + +* derive payment-system options from schema tuple ([#58](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/58)) ([#75](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/75)) ([e67c47b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e67c47b102329877eb5d9c9d8bb4ea7c0bc70883)) + +### Build + +* use native openapi-typescript --check ([#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#77](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/77)) ([df05c8c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/df05c8c7dbb5d6816fb95d54ca43cd0d83eecd48)) + +### CI + +* keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend[#62](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/62)) ([#86](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/86)) ([d409a29](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d409a297e5f9ae5658d4efa958bda9a012a859a1)) +* recheck PR title on edited without the full pipeline (WealthLedger/frontend[#64](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/64)) ([bc28973](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/bc28973d1ad5e15bfbc2e5b73b00ba63449933db)) + +### Tests + +* await client settlement in late-response cases (WealthLedger/frontend[#61](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/61)) ([#81](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/81)) ([f3725c2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f3725c202bd8cef4ba711085df82a921a2da6b7d)) +* cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/59)) ([#90](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/90)) ([5134106](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/513410669680480a1f68f5720744cc85e43c8286)) +* cover null events and income envelopes on account pages ([#72](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/72)) ([#79](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/79)) ([e5f8bbe](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e5f8bbe33cd438f432281818c5276abe9a425646)) +* cover null resource envelopes for account pages ([#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#71](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/71)) ([#78](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/78)) ([f7f53ff](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f7f53ff8e4de3c1d06fac6063974ff9667145ed6)) + +### Maintenance + +* adopt linear git model in frontend docs ([#91](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/91)) ([eefdde2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/eefdde29f8da269581e90bd797078e10a39e472f)) + ## [1.1.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.0.0...v1.1.0) (2026-10-12) ### Features diff --git a/README.md b/README.md index d65a396..c136094 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,10 @@ npm run dev Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1) 8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет -staged diff с полной редакцией секретов, затем запускает formatting и FSD checks. -Находка или отсутствие Gitleaks блокирует commit. +staged diff с полной редакцией секретов, затем запускает установленный +`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и +не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей. +Находка или отсутствие Gitleaks либо lint-staged блокирует commit. Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`. ## Контейнерный image @@ -116,7 +118,8 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` последовательно на PR; на push в `develop`/`main` — `security → verify`. Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. -Изменение заголовка PR тоже повторяет проверки Conventional Commits. +Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow, +только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются. Новый запуск отменяет устаревший для того же PR/ветки и типа события (требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`. Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет @@ -214,36 +217,59 @@ preset по имени из `.release-it.json`. Версия preset закреп Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`; общие правила — в Engineering Workflow по ссылке из AGENTS. -Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. +PR сливается squash или rebase-merge; merge-коммит не используется. `main` +обновляется только fast-forward из `develop`, поэтому история линейна: + +```text +/- --squash--> develop --ff-only--> main --> tag vX.Y.Z +``` + +Инвариант: `origin/main` всегда предок `origin/develop`. Проверка: + +```bash +git fetch -q origin +git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN +``` + Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release), включая `spec:`. Версии вручную не повышай. -1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что - `develop` содержит последний релиз. Создай `release/` от проверенного - `develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в - `release/hotfix-` и проходит тот же процесс. +1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь + инвариант. Создай `release/X.Y.Z` от проверенного `develop`; + `nvm use --silent`, `npm ci`. 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, - открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных - `verify`, `security`, `commits` выполни fast-forward-only merge. Если `main` - изменился, согласуй историю и повтори проверки кандидата. -4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией + открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных + `verify`, `security`, `commits` выполни fast-forward-only merge. Если + `develop` изменился, обнови release-ветку и повтори проверки кандидата. +4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only. +5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: `git tag -a vX.Y.Z -m "Release X.Y.Z"`, затем отправь только `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. -5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь, - что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после - merge создай отсутствующий тег без нового bump; до merge исправь и проверь - существующий кандидат без повторного повышения версии. + +Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3. +Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег +без нового bump; до merge исправь и проверь существующий кандидат без +повторного повышения версии. + +Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают. +Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом; +пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение +требует явного решения: `release/hotfix-` от `main`, PR в `main`, тег, +затем merge `main → develop` для восстановления инварианта. `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход -администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены. +администратором, включена блокировка устаревшей ветки, обязательны `verify`, +`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles +репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash, +rebase-merge и fast-forward-only; стиль по умолчанию — squash. Workflow сам не включает эти настройки. Отдельное review approval для одного владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. diff --git a/package-lock.json b/package-lock.json index ecc5c23..224a051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wealth-ledger-frontend", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wealth-ledger-frontend", - "version": "1.1.0", + "version": "1.2.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 9ebff02..696638b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wealth-ledger-frontend", "private": true, - "version": "1.1.0", + "version": "1.2.0", "type": "module", "engines": { "node": "24.21.0" @@ -20,7 +20,7 @@ "test:coverage": "npm run build:packages && vitest run --coverage", "test:lint": "node --test scripts/test-eslint.mjs", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", - "openapi:check": "node scripts/check-openapi.mjs", + "openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check", "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 && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test: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", diff --git a/scripts/check-openapi.mjs b/scripts/check-openapi.mjs deleted file mode 100644 index d9a9ac5..0000000 --- a/scripts/check-openapi.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import nodePath from "node:path"; - -const temporaryDirectory = await mkdtemp( - nodePath.join(tmpdir(), "wealth-ledger-openapi-"), -); - -const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts"); -const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts"); -const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js"); -let exitCode = 0; - -try { - const generated = spawnSync( - process.execPath, - [cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath], - { encoding: "utf8" }, - ); - - if (generated.status === 0) { - const [committed, current] = await Promise.all([ - readFile(committedPath, "utf8"), - // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. - readFile(generatedPath, "utf8"), - ]); - - if (committed !== current) { - console.error( - "Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.", - ); - exitCode = 1; - } - } - else { - process.stderr.write(generated.stderr || generated.stdout); - exitCode = generated.status ?? 1; - } -} -finally { - await rm(temporaryDirectory, { force: true, recursive: true }); -} - -process.exitCode = exitCode; diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs index 65fdbe7..9737b8d 100644 --- a/scripts/test-eslint.mjs +++ b/scripts/test-eslint.mjs @@ -85,7 +85,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as "security/detect-non-literal-regexp", ], [ - "scripts/check-openapi.mjs", + "scripts/test-pre-commit.mjs", "import { exec } from \"node:child_process\"; exec(process.argv[2]);", "security/detect-child-process", ], diff --git a/scripts/test-pre-commit.mjs b/scripts/test-pre-commit.mjs index c2246d0..3d74f8b 100644 --- a/scripts/test-pre-commit.mjs +++ b/scripts/test-pre-commit.mjs @@ -47,6 +47,12 @@ test("pre-commit scans the index, redacts findings and fails closed", () => { } git("init", "--quiet"); + // The hook must run the installed lint-staged binary, never download one. + const localBin = nodePath.join(cwd, "node_modules", ".bin"); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + mkdirSync(localBin, { recursive: true }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(localBin, "lint-staged"), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 }); // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n"); git("add", "sample.txt"); @@ -77,3 +83,37 @@ test("pre-commit scans the index, redacts findings and fails closed", () => { rmSync(cwd, { force: true, recursive: true }); } }); + +test("pre-commit never downloads tools and blocks when lint-staged is missing", () => { + const hook = nodePath.resolve(".husky/pre-commit"); + const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-")); + const bin = nodePath.join(cwd, "bin"); + const marker = nodePath.join(cwd, "downstream"); + + try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + mkdirSync(bin); + // Gitleaks passes; npx/npm must never be reached, even if present on PATH. + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(bin, "gitleaks"), "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + + for (const command of ["npx", "npm"]) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 }); + } + + const result = spawnSync("/bin/sh", ["-e", hook], { + cwd, + encoding: "utf8", + env: { ...process.env, PATH: `${bin}${nodePath.delimiter}${process.env.PATH}` }, + }); + + assert.notEqual(result.status, 0, "missing lint-staged must block commit"); + assert.match(result.stderr, /lint-staged is not installed/); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + assert.ok(!existsSync(marker), "hook must not invoke npx or npm"); + } + finally { + rmSync(cwd, { force: true, recursive: true }); + } +}); diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index 1226035..a7dacf9 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -18,12 +18,21 @@ import type { IncomeAnalytics } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; import { AuthProvider } from "@/features/auth"; +import { defaultPeriod, formatDate } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; +// Mantine calendar labels follow dayjs default (English) locale. +function dayLabel(date: string) { + const monthName = new Intl.DateTimeFormat("en", { month: "long" }) + .format(new Date(`${date}T12:00:00Z`)); + + return `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`; +} + const accounts = [ { id: "a", name: "Основной" }, { id: "b", name: "ИИС" }, @@ -95,10 +104,6 @@ const analytics: IncomeAnalytics }, ], period: { from: "2026-08-01", to: "2026-08-31" }, - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [], - }, totalsByCurrency: [ { coupon: { currency: "RUB", value: "2" }, @@ -107,7 +112,6 @@ const analytics: IncomeAnalytics total: { currency: "RUB", value: "2" }, }, ], - yearComparison: null, }; const CurrentLocation: FC = () => { @@ -238,7 +242,7 @@ describe("All accounts routes", () => { ); }); - it.each(["operations", "events", "income"])( + it.each(["operations", "income"])( "restores applied dates when switching accounts in %s", async (section) => { const search = "?from=2026-08-01&to=2026-08-31"; @@ -266,6 +270,31 @@ describe("All accounts routes", () => { }, ); + it("restores applied dates when switching accounts in events", async () => { + // The events calendar blocks past dates, so pick within selectable dates. + const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`; + renderPage(`/accounts/all/events${search}`); + const picker = await screen.findByLabelText("Период"); + fireEvent.click(picker); + const [fromDate, toDate] = defaultPeriod(); + fireEvent.click( + await screen.findByRole("button", { name: dayLabel(fromDate) }), + ); + fireEvent.click( + await screen.findByRole("button", { name: dayLabel(toDate) }), + ); + expect(screen.getByLabelText("Период")).toHaveTextContent( + `${formatDate(fromDate)} – ${formatDate(toDate)}`, + ); + fireEvent.click(screen.getByRole("combobox", { name: "Счёт" })); + fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); + await waitFor(() => + expect(screen.getByLabelText("Текущий путь")).toHaveTextContent( + `/accounts/b/events${search}`, + ), + ); + }); + it("groups the same security and links its accounts and overview sections", async () => { renderPage("/accounts/all"); expect(await screen.findAllByText("Сбербанк")).toHaveLength(1); diff --git a/src/entities/bank-card/model/error-code.ts b/src/entities/bank-card/model/error-code.ts new file mode 100644 index 0000000..95e109d --- /dev/null +++ b/src/entities/bank-card/model/error-code.ts @@ -0,0 +1,7 @@ +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String(error.code) + : undefined; +} + +export { errorCode }; diff --git a/src/entities/bank-card/model/form.schema.ts b/src/entities/bank-card/model/form.schema.ts index c5aa609..db9c797 100644 --- a/src/entities/bank-card/model/form.schema.ts +++ b/src/entities/bank-card/model/form.schema.ts @@ -6,6 +6,7 @@ import { calendarDate } from "@/shared/lib"; import { bankToday } from "./form-validation"; import { moneyCents } from "./money"; const MAX_CARD_NAME_LENGTH = 100; +const paymentSystemValues = ["mir", "visa", "mastercard"] as const; const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n; const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n; @@ -63,7 +64,7 @@ const bankCardSchema = z.object({ openingBalance: moneySchema, openingDate: bankDateSchema, paymentSystem: z - .enum(["", "mir", "visa", "mastercard"], { + .enum(["", ...paymentSystemValues], { error: validationMessage("bank.validation.paymentSystem"), }) .optional(), @@ -75,5 +76,6 @@ export { bankCardSchema, type BankCardValues, bankDateSchema, + paymentSystemValues, positiveMoneySchema, }; diff --git a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts index 3238e5b..0dc5c32 100644 --- a/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts +++ b/src/entities/bank-card/ui/BankCardForm/BankCardFields.vm.ts @@ -1,8 +1,7 @@ import { useController, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { BankCardValues } from "../../model/form.schema"; - +import { type BankCardValues, paymentSystemValues } from "../../model/form.schema"; import { bankDateField } from "../bankDateField"; function useBankCardFieldsVm() { @@ -16,7 +15,7 @@ function useBankCardFieldsVm() { const { field: openingDateField } = useController({ control, name: "openingDate" }); const { field: paymentSystemField } = useController({ control, name: "paymentSystem" }); - const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({ + const paymentSystems = paymentSystemValues.map(value => ({ label: t(`bank.paymentSystems.${value}`), value, })); diff --git a/src/entities/bank-card/ui/BankFeedback.tsx b/src/entities/bank-card/ui/BankFeedback.tsx index e9e2861..0d1f4e1 100644 --- a/src/entities/bank-card/ui/BankFeedback.tsx +++ b/src/entities/bank-card/ui/BankFeedback.tsx @@ -3,7 +3,7 @@ import type { FC } from "react"; import { Alert, Button, Group, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { apiErrorCode } from "@/shared/api"; +import { errorCode } from "../model/error-code"; type BankFeedbackProps = { cached?: boolean; @@ -14,7 +14,7 @@ type BankFeedbackProps = { const BankFeedback: FC = (props) => { const { t } = useTranslation(); - const code = apiErrorCode(props.error); + const code = errorCode(props.error); return ( diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx index ffd3e4f..288d9f9 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx @@ -10,6 +10,7 @@ import { formatMoney } from "@/shared/lib"; import type { BankSummary } from "../../api/types"; import { bankOperationAppearance } from "../../model/operation-appearance"; +import { BankFeedback } from "../BankFeedback"; import classes from "./BankSummary.module.css"; const CATEGORY_COLUMNS = 2; @@ -17,9 +18,10 @@ const CATEGORY_COLUMNS = 2; type BankSummaryViewProps = { all?: boolean; data?: BankSummary; - error: boolean; + error: unknown; fetching: boolean; loading: boolean; + retry?: () => void; }; const BankSummaryViewCategories: FC = (props) => { @@ -122,7 +124,14 @@ const BankSummaryView: FC = (props) => { - {props.error && !props.data && {t("bank.error")}} + {Boolean(props.error) && ( + + )} ); }; diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index 411272b..2c9aa3a 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -36,6 +36,12 @@ function entryLabelKey(operation: BrokerageOperation): string { return `ledger.category.${operation.category}`; } +function isIncomePayment(operation: BrokerageOperation): boolean { + return [OperationType.Coupon, OperationType.Dividend].some(type => + isOperationType(operation.type, type), + ); +} + function isOperationType(value: unknown, expected: OperationType): boolean { return value === expected; } @@ -109,4 +115,4 @@ function paymentPresentation( }; } -export { operationLabelKey, paymentPresentation }; +export { isIncomePayment, operationLabelKey, paymentPresentation }; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index bd49a21..1c3d1f3 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -11,10 +11,11 @@ import { formatSnapshotTime, instrumentLogoUrl, } from "@/shared/lib"; +import { PayoutCard } from "@/shared/ui"; import type { BrokerageOperation } from "../api/useBrokerageOperations"; -import { operationLabelKey, paymentPresentation } from "../model/display"; +import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display"; type LedgerEntryRowProps = { operation: BrokerageOperation; @@ -75,6 +76,28 @@ const LedgerEntryRow: FC = (props) => { } else details = undefined; + if (isIncomePayment(operation)) { + const extraMetadata = [ + instrument, + props.withDetails ? null : operation.account?.name, + ].filter(Boolean).join(" · "); + + return ( + + ); + } + return ( = (props) => { const date = formatDate(event.date); + if (event.type === "coupon" || event.type === "dividend") { + const title = [t(`events.type.${event.type}`), event.displayName].join(" · "); + + const extraMetadata = [event.isin, event.account?.name] + .filter(Boolean) + .join(" · "); + + return ( + + ); + } + const metadata = [ date, event.dateKind === "registry" @@ -40,7 +62,7 @@ const BrokerageEventCard: FC = (props) => { title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} tone="neutral" value={projectedAmount} - valueLabel={t("events.projectedAmount")} + valueLabel={t("payout.forecast")} /> ); }; diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts index 449eef3..a6357e8 100644 --- a/src/entities/income/api/useIncome.ts +++ b/src/entities/income/api/useIncome.ts @@ -4,9 +4,6 @@ type IncomeAnalytics = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] >; -type IncomeAnalyticsMeta - = paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"]; - type IncomeSummary = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] >; @@ -47,7 +44,6 @@ function useIncomeSummary(accountId?: string) { export { type IncomeAnalytics, - type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/entities/income/index.ts b/src/entities/income/index.ts index 7ecf287..a277e6d 100644 --- a/src/entities/income/index.ts +++ b/src/entities/income/index.ts @@ -1,6 +1,5 @@ export { type IncomeAnalytics, - type IncomeAnalyticsMeta, type IncomeSummary, useIncomeAnalytics, useIncomeSummary, diff --git a/src/features/auth/model/auth.schema.test.ts b/src/features/auth/model/auth.schema.test.ts new file mode 100644 index 0000000..1e71982 --- /dev/null +++ b/src/features/auth/model/auth.schema.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { loginSchema, registerSchema } from "./auth.schema"; + +const email = "user@example.com"; + +function canLogIn(password: string) { + return loginSchema.safeParse({ email, password }).success; +} + +function canRegister(password: string, confirmation = password) { + return registerSchema.safeParse({ + email, + password, + passwordConfirmation: confirmation, + }).success; +} + +// Register policy: 8..72 printable ASCII (bcrypt reads 72 bytes, so non-ASCII could be +// truncated). Login stays lenient so accounts created under the old policy still sign in. +describe("auth password policy", () => { + it.each([ + ["7 chars", "a".repeat(7), false], + ["8 chars", "a".repeat(8), true], + ["72 chars", "a".repeat(72), true], + ["73 chars", "a".repeat(73), false], + ["printable symbols and space", "Aa1 !~\"#$%&'()*+,-./:;<=>?@", true], + ["Cyrillic, 8+ chars", "пароль-пароль", false], + ["emoji", "passw😀rd", false], + ["tab", "pass\tword1", false], + ["newline", "password1\n", false], + ["DEL", "password\u{7F}1", false], + ])("register: %s", (_, password, accepted) => { + expect(canRegister(password)).toBe(accepted); + }); + + it("register compares the raw password with its confirmation", () => { + const padded = " spaced pass "; + + expect(canRegister(padded)).toBe(true); + expect(canRegister(padded, padded.trim())).toBe(false); + }); + + it.each([ + ["legacy Cyrillic password", "пароль-пароль", true], + ["short legacy password", "short", true], + ["72 bytes", "a".repeat(72), true], + ["73 bytes", "a".repeat(73), false], + ["36 Cyrillic letters = 72 bytes", "я".repeat(36), true], + ["37 Cyrillic letters = 74 bytes", "я".repeat(37), false], + ["empty", "", false], + ])("login: %s", (_, password, accepted) => { + expect(canLogIn(password)).toBe(accepted); + }); +}); diff --git a/src/features/auth/model/auth.schema.ts b/src/features/auth/model/auth.schema.ts index a602a77..4df28c7 100644 --- a/src/features/auth/model/auth.schema.ts +++ b/src/features/auth/model/auth.schema.ts @@ -7,8 +7,10 @@ const EMAIL_PART_COUNT = 2; const ASCII_SPACE = 32; const ASCII_DELETE = 127; const MAX_EMAIL_LENGTH = 254; -const MIN_PASSWORD_BYTES = 12; +const MIN_PASSWORD_LENGTH = 8; const MAX_PASSWORD_BYTES = 72; +// Printable ASCII keeps characters == bytes, within the bcrypt 72-byte limit. +const ASCII_TILDE = 126; function isEmailCharacterInvalid(character: string): boolean { const codePoint = character.codePointAt(0); @@ -20,6 +22,14 @@ function isEmailCharacterInvalid(character: string): boolean { ); } +function isPrintableAscii(value: string): boolean { + return value.split("").every((character) => { + const code = character.codePointAt(0) ?? 0; + + return code >= ASCII_SPACE && code <= ASCII_TILDE; + }); +} + const email = z .string() .trim() @@ -33,28 +43,42 @@ const email = z { error: validationMessage("auth.validation.invalidEmail") }, ); -const password = z +// Login stays lenient: accounts created under an older policy must still sign in. +const loginPassword = z .string() + .min(1) .refine( - value => - new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES - && new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, - { error: validationMessage("auth.validation.invalidPasswordLength") }, + value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, + { error: validationMessage("auth.validation.passwordTooLong") }, ); +const registerPassword = z + .string() + .min(MIN_PASSWORD_LENGTH, { + error: validationMessage("auth.validation.invalidPasswordLength"), + }) + .max(MAX_PASSWORD_BYTES, { + error: validationMessage("auth.validation.invalidPasswordLength"), + }) + .refine(isPrintableAscii, { + error: validationMessage("auth.validation.invalidPasswordLength"), + }); + const loginSchema = z.object({ email, - password, + password: loginPassword, passwordConfirmation: z.string().optional(), }); -const registerSchema = loginSchema.refine( - value => value.password === value.passwordConfirmation, - { - error: validationMessage("auth.validation.passwordsDoNotMatch"), - path: ["passwordConfirmation"], - }, -); +const registerSchema = loginSchema + .extend({ password: registerPassword }) + .refine( + value => value.password === value.passwordConfirmation, + { + error: validationMessage("auth.validation.passwordsDoNotMatch"), + path: ["passwordConfirmation"], + }, + ); type AuthFormValues = z.input; diff --git a/src/features/auth/ui/AuthForm/AuthForm.test.tsx b/src/features/auth/ui/AuthForm/AuthForm.test.tsx index 2e0cd61..4c73b9c 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.test.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.test.tsx @@ -30,4 +30,79 @@ describe("AuthForm", () => { await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()); expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument(); }); + + it.each([ + ["register", "Регистрация", "Уже есть аккаунт? Войти"], + ["login", "Вход", "Нет аккаунта? Зарегистрироваться"], + ])("blocks the switch-auth link while %s is submitting", async (_mode, submitLabel, linkLabel) => { + const { promise, resolve } = Promise.withResolvers(); + const onSubmit = vi.fn(() => promise); + render( + + + + + , + ); + + fireEvent.change(screen.getByLabelText("Email"), { + target: { value: "user@example.com" }, + }); + fireEvent.change(screen.getByLabelText("Пароль"), { + target: { value: "a secure password" }, + }); + + if (_mode === "register") { + fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { + target: { value: "a secure password" }, + }); + } + + fireEvent.click(screen.getByRole("button", { name: submitLabel })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalled()); + expect(screen.queryByRole("link", { name: linkLabel })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: linkLabel })).toBeDisabled(); + + resolve(); + await screen.findByRole("link", { name: linkLabel }); + }); + + // Register policy is 8..72 printable ASCII; login accepts legacy passwords. + it.each([ + ["register", "Регистрация", "пароль-пароль", false], + ["register", "Регистрация", "passw😀rd", false], + ["register", "Регистрация", "a".repeat(7), false], + ["register", "Регистрация", "a".repeat(8), true], + ["register", "Регистрация", "a".repeat(72), true], + ["register", "Регистрация", "a".repeat(73), false], + ["login", "Вход", "пароль-пароль", true], + ])("%s submits password %j: %s", async (mode, submitLabel, password, submitted) => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + + render( + + + + + , + ); + + fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } }); + fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: password } }); + + if (mode === "register") { + fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { target: { value: password } }); + } + + fireEvent.click(screen.getByRole("button", { name: submitLabel })); + + await waitFor(() => expect( + screen.queryByText(/Пароль: от 8 до 72 символов/) === null, + ).toBe(submitted)); + expect(onSubmit).toHaveBeenCalledTimes(submitted ? 1 : 0); + }); }); diff --git a/src/features/auth/ui/AuthForm/AuthForm.tsx b/src/features/auth/ui/AuthForm/AuthForm.tsx index 3149dab..b273e72 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.tsx @@ -1,4 +1,4 @@ -import type { FC } from "react"; +import type { FC, ReactNode } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core"; @@ -15,7 +15,7 @@ import { type AuthFormProps = { onSubmit: (email: string, password: string) => Promise; - register?: true; + register?: boolean; }; const AuthForm: FC = (props) => { @@ -45,6 +45,24 @@ const AuthForm: FC = (props) => { }); const to = register ? "/login" : "/register"; + const switchLabel = t(register ? "auth.haveAccount" : "auth.noAccount"); + + let switchLink: ReactNode; + + if (isSubmitting) { + switchLink = ( + + ); + } + else { + switchLink = ( + + ); + } return ( @@ -77,9 +95,7 @@ const AuthForm: FC = (props) => { {t(register ? "auth.register" : "auth.login")} - + {switchLink} diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index 28b35d0..d30379b 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -78,4 +78,35 @@ describe("read-only screen synchronization", () => { await act(async () => result.current.refresh()); await waitFor(() => expect(gets).toBe(2)); }); + + it("does not manually refetch disabled resources", async () => { + vi.useFakeTimers(); + const refetch = vi.fn(() => Promise.resolve()); + + const resource = { + isEnabled: false, + isError: true, + isFetching: false, + queryKey: [], + refetch, + }; + + try { + const { result, unmount } = renderHook(() => + useScreenSync({ screen: "accounts" }, [resource]), + ); + + await act(async () => { + await result.current.refresh(); + await result.current.retry(); + }); + await act(() => vi.advanceTimersByTime(1_800_000)); + + expect(refetch).not.toHaveBeenCalled(); + unmount(); + } + finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index fda4122..5fa43f5 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -5,6 +5,7 @@ import { useEffect, useRef } from "react"; import type { InvestmentResourceMeta } from "@/shared/api"; type ScreenResource = { + isEnabled: boolean; isError: boolean; isFetching: boolean; meta?: InvestmentResourceMeta; @@ -27,23 +28,31 @@ function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) { useEffect(() => { const timer = setInterval(() => { - for (const resource of resourcesRef.current) + for (const resource of resourcesRef.current) { + if (!resource.isEnabled || resource.isFetching) continue; void resource.refetch().catch(() => {}); + } }, READ_INTERVAL_MS); return () => clearInterval(timer); }, []); - const isRefreshing = resources.some(resource => resource.isFetching); + const isRefreshing = resources.some( + resource => resource.isEnabled && resource.isFetching, + ); + + const eligible = resources.filter( + resource => resource.isEnabled && !resource.isFetching, + ); const refresh = async () => { if (isRefreshing) return; - await Promise.allSettled(resources.map(resource => resource.refetch())); + await Promise.allSettled(eligible.map(resource => resource.refetch())); }; // Retry only the HTTP read; source attempts are reported in resource metadata. const retry = async () => { - const failed = resources.filter(resource => resource.isError); + const failed = eligible.filter(resource => resource.isError); if (failed.length > 0) await Promise.allSettled(failed.map(resource => resource.refetch())); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index d9752ab..8bb0142 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -20,7 +20,7 @@ import { import { beforeEach, describe, expect, it, vi } from "vitest"; import { configureAuthClient } from "@/shared/api"; -import { defaultPeriod, formatDate } from "@/shared/lib"; +import { defaultPeriod, formatDate, moscowDate } from "@/shared/lib"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; @@ -253,37 +253,58 @@ describe("Upcoming event date filters", () => { expect(from).toHaveTextContent("01.02.2026 – 28.02.2026"); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect(searches).toEqual(["?from=2026-02-01&to=2026-02-28"]); - fireEvent.click(from); + + // The calendar opens on the applied month, so reset to the default + // range first, then pick today and a future date. + fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); + await waitFor(() => expect(screen.getByLabelText("URL")).toBeEmptyDOMElement()); + const picker = await screen.findByLabelText("Период"); + + const [today] = defaultPeriod(); + const [year = 1970, month = 0, day = 1] = today.split("-").map(Number); + + const future = moscowDate(new Date( + Date.UTC(year, month - 1, day + 10, 12), + )); + + // Mantine calendar labels follow dayjs default (English) locale. + const calendarLabel = (date: string) => { + const monthName = new Intl.DateTimeFormat("en", { month: "long" }) + .format(new Date(`${date}T12:00:00Z`)); + + const pattern = `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`; + + // Dates are constructed above from validated defaultPeriod output. + // eslint-disable-next-line security/detect-non-literal-regexp -- see comment above + return new RegExp(pattern, "i"); + }; + + fireEvent.click(picker); fireEvent.click( - await screen.findByRole("button", { name: /10 February 2026/i }), + await screen.findByRole("button", { name: calendarLabel(today) }), ); fireEvent.click( - await screen.findByRole("button", { name: /28 February 2026/i }), + await screen.findByRole("button", { name: calendarLabel(future) }), ); - expect(searches).toHaveLength(1); + // The reset itself fetched the default range; picking dates applies only on submit. + expect(searches).toEqual([ + "?from=2026-02-01&to=2026-02-28", + `?from=${defaultFrom}&to=${defaultTo}`, + ]); fireEvent.click(screen.getByRole("button", { name: "Применить" })); await waitFor(() => - expect(searches.at(-1)).toBe("?from=2026-02-10&to=2026-02-28"), + expect(searches.at(-1)).toBe(`?from=${today}&to=${future}`), ); expect(await screen.findByLabelText("Период")).toHaveTextContent( - "10.02.2026 – 28.02.2026", + `${formatDate(today)} – ${formatDate(future)}`, ); fireEvent.click(screen.getByRole("button", { name: "History back" })); await waitFor(() => expect(screen.getByLabelText("Период")).toHaveTextContent( - "01.02.2026 – 28.02.2026", + `${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`, ), ); - expect(screen.getByLabelText("URL")).toHaveTextContent( - "?from=2026-02-01&to=2026-02-28", - ); - fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); - await waitFor(() => - expect(searches.at(-1)).toBe(`?from=${defaultFrom}&to=${defaultTo}`), - ); - expect(await screen.findByLabelText("Период")).toHaveTextContent( - `${formatDate(defaultFrom)} – ${formatDate(defaultTo)}`, - ); + expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); expect(screen.getByRole("button", { name: "Применить" })).toBeDisabled(); expect( screen.getByText("За выбранный период событий не найдено"), diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 0f3b0e4..8874f94 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -156,7 +156,7 @@ describe("AccountEventsPage", () => { expect(screen.getAllByText(/RU000A0JX0J2$/)).toHaveLength(1); expect(screen.getByText("25.08.2026")).toBeVisible(); expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument(); - expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2); + expect(screen.getAllByText("Прогноз")).toHaveLength(2); const logo = screen.queryByAltText(""); expect(logo).toHaveAttribute( "src", @@ -211,4 +211,30 @@ describe("AccountEventsPage", () => { expect(status).toHaveAttribute("aria-busy", "true"); expect(within(status).queryByRole("button")).not.toBeInTheDocument(); }); + + it("keeps a null events envelope loading instead of an empty period", () => { + query.mockReturnValue( + { + data: null, + isError: false, + isLoading: false, + meta: { + availability: "available", + completeness: "complete", + lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" }, + }, + refetch: vi.fn(), + } as never, + ); + + renderPage(); + + expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( + "aria-busy", + "true", + ); + expect( + screen.queryByText("За выбранный период событий не найдено"), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 41be198..51ff5eb 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -21,7 +21,7 @@ type AccountEventsPageViewModel canApply: boolean; draftPeriod: [null | string, null | string]; events: BrokerageEvent[]; - maxDate: string; + minDate: string; onApply: () => void; onPeriodChange: (value: [null | string, null | string]) => void; onPeriodPopoverOpen: () => void; @@ -49,7 +49,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { const [draftFrom, draftTo] = draftPeriod; - const [maxDate, setMaxDate] = useState(() => moscowDate(new Date())); + const [minDate, setMinDate] = useState(() => moscowDate(new Date())); const scope = JSON.stringify([accountId, appliedFrom, appliedTo]); const [previousScope, setPreviousScope] = useState(scope); @@ -105,7 +105,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { && isValidPeriod(draftFrom, draftTo), draftPeriod, events: events.data.events, - maxDate, + minDate, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParameters({ @@ -116,7 +116,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel { onPeriodChange: value => setDraftPeriod( value[0] === null && value[1] === null ? defaultPeriod() : value, ), - onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), + onPeriodPopoverOpen: () => setMinDate(moscowDate(new Date())), onReset, partial: events.meta?.completeness === "incomplete", source: events.meta, diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index ee44db1..e095af6 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -72,7 +72,7 @@ const AccountEventsPageLoaded: FC = (props) => { clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} label={t("ledger.period")} - maxDate={props.maxDate} + minDate={props.minDate} onChange={props.onPeriodChange} popoverProps={{ middlewares: { shift: { crossAxis: true } }, diff --git a/src/pages/account-income/model/display.test.ts b/src/pages/account-income/model/display.test.ts index ce68f74..19a247e 100644 --- a/src/pages/account-income/model/display.test.ts +++ b/src/pages/account-income/model/display.test.ts @@ -2,168 +2,25 @@ import { describe, expect, it } from "vitest"; import type { IncomeAnalytics } from "@/entities/income"; -import { resource } from "@/test/resource"; - import { toIncomeDisplayModel } from "./display"; -const analytics: IncomeAnalytics = { - accountId: "account-1", - bucketGranularity: "day", - buckets: [], - calculatedAt: "2026-08-27T00:00:00Z", - entries: [], - instruments: [], - period: { from: "2026-08-01", to: "2026-08-27" }, - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [comparison("RUB")], - }, - totalsByCurrency: [], - yearComparison: null, -}; +describe("income payments display", () => { + it("keeps repeated IDs, order and logo revisions without reconstructing financial data", () => { + const payment = { + id: "same", + instrumentKey: "bond:RUB", + logoName: null, + } as IncomeAnalytics["entries"][number]; -function comparison(currency: string) { - return { - compared: summary(currency, "1", "0"), - currency, - difference: summary(currency, "9", "2"), - selected: summary(currency, "10", "2"), - }; -} + const analytics = { + entries: [payment, payment, { ...payment, logoName: "bond.png" }], + } as IncomeAnalytics; -function money(currency: string, value: string) { - return { currency, value }; -} + const display = toIncomeDisplayModel(analytics); -function summary(currency: string, coupon: string, dividend: string) { - return { - coupon: money(currency, coupon), - currency, - dividend: money(currency, dividend), - total: money(currency, String(Number(coupon) + Number(dividend))), - }; -} - -describe("income comparison display", () => { - it.each([ - ["25", "100", "+25 %"], - ["-25", "100", "-25 %"], - ["0", "100", "0 %"], - ["-0.000", "100", "0 %"], - ["0.0001", "100", "0 %"], - ["1", "3", "+33,3 %"], - ["0.01", "0.04", "+25 %"], - ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], - [ - "10", - "0", - null, - ], - [ - "0", - "0", - null, - ], - [ - "10", - "-0.00", - null, - ], - [ - "10", - "-100", - null, - ], - [ - "9".repeat(400), - "1", - null, - ], - [ - "1", - "9".repeat(400), - null, - ], - ])("formats change %s against base %s", (difference, base, expected) => { - const value = comparison("RUB"); - value.difference.coupon = money("RUB", difference); - value.compared.coupon = money("RUB", base); - - const display = toIncomeDisplayModel({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [value], - }, - }); - - const result = display?.comparison?.values[0]; - expect( - result?.percentages.coupon?.replaceAll("\u{A0}", " ") - - ?? null, - ).toBe(expected); - expect(result?.difference.coupon.value).toBe(difference); - expect(result?.compared.coupon.value).toBe(base); - }); - - it("formats calendar ranges across years and preserves separate currencies", () => { - const display = toIncomeDisplayModel({ - ...analytics, - period: { from: "2025-12-01", to: "2026-01-09" }, - yearComparison: { - period: { from: "2024-12-01", to: "2025-01-09" }, - totalsByCurrency: [comparison("RUB"), comparison("USD")], - }, - }); - - expect(display?.comparison?.selectedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2025 – 09.01.2026", + expect(display.entries.map(({ operation }) => operation)).toEqual( + analytics.entries, ); - expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( - "01.12.2024 – 09.01.2025", - ); - expect(display?.comparison?.values.map(value => value.currency)).toEqual([ - "RUB", - "USD", - ]); + expect(new Set(display.entries.map(({ key }) => key)).size).toBe(3); }); }); - -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, - lastAttempt: { - finishedAt: "2026-08-14T00:01:00Z", - problems: [], - startedAt: "2026-08-14T00:00:00Z", - state: "failed" as const, - }, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - }; - - 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(); - } -}); diff --git a/src/pages/account-income/model/display.ts b/src/pages/account-income/model/display.ts index bcd1b7c..371d25f 100644 --- a/src/pages/account-income/model/display.ts +++ b/src/pages/account-income/model/display.ts @@ -1,102 +1,11 @@ -import type { IncomeAnalytics, IncomeAnalyticsMeta } from "@/entities/income"; - -import { formatDate } from "@/shared/lib"; - -import type { IncomePeriod } from "./period"; - -type ComparisonNotice - = | "comparisonFailed" - | "comparisonPending" - | "previousUnavailable" - | "yearUnavailable"; +import type { IncomeAnalytics } from "@/entities/income"; type IncomeDisplayModel = { - comparison: null - | { - comparedPeriod: string; - selectedPeriod: string; - values: (NonNullable< - IncomeAnalytics["yearComparison"] - >["totalsByCurrency"][number] & { - percentages: Record<"coupon" - | "dividend" - | "total", null | string>; - })[]; - }; entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; - previousComparison: IncomeDisplayModel["comparison"]; - previousNotice: ComparisonNotice; - yearNotice: ComparisonNotice; }; -function comparisonDisplay( - period: IncomePeriod, - comparison: IncomeAnalytics["yearComparison"], -): IncomeDisplayModel["comparison"] { - if (!comparison) - return null; - - return { - comparedPeriod: formatComparisonPeriod(comparison.period), - selectedPeriod: formatComparisonPeriod(period), - values: comparison.totalsByCurrency.map(value => ({ - ...value, - percentages: { - coupon: formatIncomeChange( - value.difference.coupon.value, - value.compared.coupon.value, - ), - dividend: formatIncomeChange( - value.difference.dividend.value, - value.compared.dividend.value, - ), - total: formatIncomeChange( - value.difference.total.value, - value.compared.total.value, - ), - }, - })), - }; -} - -function comparisonNotice( - dependency: - IncomeAnalyticsMeta["dependencies"][number] | undefined, - unavailable: "previousUnavailable" | "yearUnavailable", -): ComparisonNotice { - if (!dependency || dependency.unavailableReason) return unavailable; - - return dependency.lastAttempt.state === "failed" - ? "comparisonFailed" - : "comparisonPending"; -} - -function formatComparisonPeriod(period: IncomePeriod): string { - return `${formatDate(period.from)} – ${formatDate(period.to)}`; -} - -function formatIncomeChange( - difference: string, - compared: string, -): null | string { - // Display-only ratio; server-provided Money values remain exact decimal strings. - const base = Number(compared); - if (!Number.isFinite(base) || base <= 0) - return null; - const ratio = Number(difference) / base; - if (!Number.isFinite(ratio)) - return null; - - return new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 1, - signDisplay: "exceptZero", - style: "percent", - }).format(ratio); -} - function toIncomeDisplayModel( analytics: IncomeAnalytics, - meta?: IncomeAnalyticsMeta, ): IncomeDisplayModel { const entries: IncomeDisplayModel["entries"] = []; const occurrences = new Map(); @@ -116,17 +25,7 @@ function toIncomeDisplayModel( }); } - const dependencies = meta?.dependencies ?? []; - const previous = dependencies.find(({ role }) => role === "previous"); - const year = dependencies.find(({ role }) => role === "year"); - - return { - comparison: comparisonDisplay(analytics.period, analytics.yearComparison), - entries, - previousComparison: comparisonDisplay(analytics.period, analytics.previous), - previousNotice: comparisonNotice(previous, "previousUnavailable"), - yearNotice: comparisonNotice(year, "yearUnavailable"), - }; + return { entries }; } export { type IncomeDisplayModel, toIncomeDisplayModel }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css index 2e737bc..27431fb 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.module.css @@ -22,123 +22,6 @@ min-width: 0; } -.comparison { - container-type: inline-size; -} - -.comparisonGrid { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: var(--mantine-spacing-sm); -} - -.comparisonPanel { - min-width: 0; - padding: var(--mantine-spacing-md); - border-radius: var(--mantine-radius-md); - border-top: 3px solid var(--mantine-color-gray-4); - background: var(--mantine-color-gray-0); -} - -.comparisonPanel[data-kind="selected"] { - border-color: var(--mantine-color-blue-6); - background: var(--mantine-color-blue-0); -} - -.comparisonPanel[data-tone="positive"] { - border-color: var(--mantine-color-green-8); - background: var(--mantine-color-green-0); -} - -.comparisonPanel[data-tone="negative"] { - border-color: var(--mantine-color-red-8); - background: var(--mantine-color-red-0); -} - -.comparisonLabel, -.comparisonValues dt { - color: var(--mantine-color-gray-7); -} - -.comparisonValues { - margin: var(--mantine-spacing-md) 0 0; - font-size: var(--mantine-font-size-sm); -} - -.comparisonValues dd { - min-width: 0; - margin: 0; - font-weight: 600; - font-variant-numeric: tabular-nums; - overflow-wrap: anywhere; -} - -.comparisonTotal { - margin-bottom: var(--mantine-spacing-md); - padding-bottom: var(--mantine-spacing-md); - border-bottom: 1px solid var(--mantine-color-gray-3); -} - -.comparisonTotal dd { - margin-top: 4px; - font-size: var(--mantine-h2-font-size); - line-height: 1.3; - font-weight: 700; - letter-spacing: -0.025em; -} - -.comparisonValues dd[data-tone="positive"] { - color: color-mix( - in srgb, - var(--mantine-color-green-9) 85%, - var(--mantine-color-black) - ); -} - -.comparisonValues dd[data-tone="negative"] { - color: var(--mantine-color-red-9); -} - -.comparisonValues dd[data-tone="neutral"] { - color: var(--mantine-color-gray-7); -} - -.comparisonPanel[data-kind="selected"] .comparisonTotal dd { - color: var(--mantine-color-blue-8); -} - -.comparisonDetail { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 4px var(--mantine-spacing-xs); - margin-top: var(--mantine-spacing-xs); -} - -.comparisonPercent { - display: inline-block; - margin-left: var(--mantine-spacing-xs); - padding: 2px 6px; - border-radius: var(--mantine-radius-sm); - background: var(--mantine-color-white); - font-size: var(--mantine-font-size-xs); - font-weight: 600; - line-height: 1.5; - letter-spacing: normal; - vertical-align: middle; -} - -.comparisonNotice { - background: var(--mantine-color-gray-0); - border-left: 3px solid var(--mantine-color-gray-4); -} - -@container (min-width: 42em) { - .comparisonGrid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} - @media (max-width: 35.999em) { .fields { grid-template-columns: minmax(0, 1fr); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 063be7e..c821122 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -72,23 +72,9 @@ const analytics }, ], period: { from: "2026-08-01", to: "2026-08-27" }, - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [comparison("RUB")], - }, totalsByCurrency: [summary("RUB", "10", "2")], - yearComparison: null, } satisfies IncomeAnalytics & { instruments: [unknown] }; -function comparison(currency: string) { - return { - compared: summary(currency, "1", "0"), - currency, - difference: summary(currency, "9", "2"), - selected: summary(currency, "10", "2"), - }; -} - function entry(): IncomeAnalytics["entries"][number] { return { accruedInterest: null, @@ -328,6 +314,24 @@ describe("AccountIncomePage", () => { ).not.toBeInTheDocument(); }); + it("keeps a null analytics envelope loading instead of an empty page", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json(resource(null)), + ), + ); + renderPage(); + expect( + await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }), + ).toBeVisible(); + expect( + screen.queryByRole("heading", { name: "Пассивный доход" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Повторить" }), + ).not.toBeInTheDocument(); + }); + it("renders the signed drill-down using opaque membership rather than instrument identity", async () => { const response = { @@ -372,19 +376,11 @@ describe("AccountIncomePage", () => { expect(within(panel).getByText("−2 ₽")).toBeInTheDocument(); }); - it("renders realized text and comparisons without subscribing to events", async () => { + it("renders realized income without comparison blocks or event requests", async () => { const eventsRequest = vi.fn(() => new HttpResponse(null, { status: 503 })); server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json( - resource({ - ...analytics, - yearComparison: { - period: { from: "2025-08-01", to: "2025-08-27" }, - totalsByCurrency: [comparison("RUB")], - }, - }), - ), + HttpResponse.json(resource(analytics)), ), http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), ); @@ -399,72 +395,9 @@ describe("AccountIncomePage", () => { expect(screen.queryByText("Динамика")).not.toBeInTheDocument(); expect(eventsRequest).not.toHaveBeenCalled(); expect(screen.getByRole("region", { name: "Выплаты" })).toBeInTheDocument(); - const year = screen.getByRole("region", { name: "Годом ранее" }); - - const selected = within(year).getByRole("region", { - name: "01.08.2026 – 27.08.2026", - }); - - expect( - screen.getByRole("region", { name: "01.08.2025 – 27.08.2025" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("heading", { name: "Предыдущий период" }), - ).toBeVisible(); - expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument(); - expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument(); - expect( - screen.getByRole("heading", { level: 2, name: "Годом ранее" }), - ).toBeInTheDocument(); - expect(screen.queryByText("Сравнения")).not.toBeInTheDocument(); - expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument(); - - for (const amount of ["10 ₽", "2 ₽", "12 ₽"]) { - expect(within(selected).getByText(amount)).toBeInTheDocument(); - } - - const difference = within(year).getByRole("region", { name: "Разница" }); - - for (const amount of ["+9 ₽", "+2 ₽", "+11 ₽"]) { - expect(within(difference).getByText(amount)).toBeInTheDocument(); - } - - expect(within(difference).getByText("+900 %")).toBeInTheDocument(); - expect(within(difference).getByText("+1 100 %")).toBeInTheDocument(); - expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument(); - expect(within(difference).getAllByText(/%$/)).toHaveLength(2); - expect( - // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. - within(difference).getByText("+2 ₽").closest("dd"), - ).toHaveTextContent(/^\+2 ₽$/); - }); - - 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 })), - ), - ); - renderPage(); - expect( - await screen.findByText("Сравнение с годом ранее недоступно."), - ).toBeInTheDocument(); - expect( - screen.queryByRole("region", { name: "Годом ранее" }), - ).not.toBeInTheDocument(); - expect( - screen.getByRole("region", { name: "Предыдущий период" }), - ).toBeVisible(); + expect(screen.queryByText("Предыдущий период")).not.toBeInTheDocument(); + expect(screen.queryByText("Годом ранее")).not.toBeInTheDocument(); + expect(screen.queryByText(/Сравнение с/)).not.toBeInTheDocument(); }); it("shows a quiet realized error", async () => { @@ -748,120 +681,3 @@ describe("income period and snapshot boundary", () => { expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); }); - -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 }, - { dependencies }, - ), - ), - ), - ); - renderPage(); - expect(await screen.findByRole("region", { name: "Выплаты" })).toBeVisible(); - expect( - screen.queryByRole("region", { name: "Год назад" }), - ).not.toBeInTheDocument(); - expect(screen.getAllByText("12 ₽").length).toBeGreaterThan(0); - expect( - screen.getAllByText("Период сравнения ещё не загружен полностью."), - ).toHaveLength(2); - expect( - screen.queryByText("Сравнение с годом ранее недоступно."), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole("region", { name: "Разница" }), - ).not.toBeInTheDocument(); - expect(screen.getByLabelText("Период")).toBeVisible(); -}); - -it("keeps complete previous comparison while year source failed without inventing its amounts", async () => { - const dependencies = [ - { - ...resource(analytics).meta, - role: "previous" as const, - - }, - { - ...resource(null).meta, - lastAttempt: { - finishedAt: "2026-08-14T00:01:00Z", - problems: [], - startedAt: "2026-08-14T00:00:00Z", - state: "failed" as const, - }, - role: "year" as const, - }, - { - ...resource(analytics).meta, - role: "selected" as const, - }, - ]; - - server.use( - http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json(resource(analytics, { dependencies })), - ), - ); - renderPage(); - expect( - await screen.findByRole("region", { name: "Предыдущий период" }), - ).toBeVisible(); - expect( - screen.getByText( - "Не удалось загрузить период сравнения. Повторите обновление.", - ), - ).toBeVisible(); - expect( - screen.queryByRole("region", { name: "Годом ранее" }), - ).not.toBeInTheDocument(); -}); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 853fcae..ae0a8e8 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -91,7 +91,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => { ? { period, state: "error" } : { state: "loading" }; - const display = toIncomeDisplayModel(analytics.data, analytics.meta); + const display = toIncomeDisplayModel(analytics.data); const onSubmit = (values: IncomeForm) => { const from = parseDraftDate(values.from); diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index 50db1bc..bef3258 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next"; import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; import classes from "../AccountIncomePage.module.css"; -import { IncomeComparisons } from "./IncomeComparisons.view"; import { IncomePayments } from "./IncomePayments.view"; import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view"; import { IncomeSummary } from "./IncomeSummary.view"; @@ -54,18 +53,6 @@ const AccountIncomePageLoaded: FC = (props) => { {t("income.taxBoundary")} - - - - ); diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx deleted file mode 100644 index 5b0ceeb..0000000 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import type { FC } from "react"; - -import { Paper, Stack, Text, Title } from "@mantine/core"; -import { useTranslation } from "react-i18next"; - -import { formatMoney } from "@/shared/lib"; - -import type { IncomeDisplayModel } from "../../../model/display"; - -import classes from "../AccountIncomePage.module.css"; - -type ComparisonProps = NonNullable & { - title: string; -}; - -type ComparisonValuesProps = { - kind: "compared" - | "difference" - | "selected"; - label: string; - percentages?: ComparisonProps["values"][number]["percentages"]; - values: ComparisonProps["values"][number]["selected"]; -}; - -type IncomeComparisonsProps = { - comparison: IncomeDisplayModel["comparison"]; - notice: string; - title: string; -}; - -const IncomeComparisons: FC = (props) => { - return ( - - {props.title} - - {props.comparison - ? - : ( - - - {props.notice} - - - )} - - ); -}; - -const Comparison: FC = (props) => { - const { t } = useTranslation(); - - return ( -
- - {props.values.map(value => ( - -
- - - - - -
-
- ))} - - {props.values.length === 0 && ( - - {t("income.comparisonEmpty")} - - )} -
-
- ); -}; - -const ComparisonValues: FC = (props) => { - const { t } = useTranslation(); - - const data_tone - = props.kind === "difference" - ? differenceTone(props.values.total.value) - : undefined; - - return ( -
- - {props.label} - - -
- {(["total", "coupon", "dividend"] as const).map((key) => { - const money - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - = props.values[key]; - - const percentage - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - = props.percentages?.[key]; - - const tone - = props.kind === "difference" - ? differenceTone(money.value) - : undefined; - - const appearanceClass - = key === "total" - ? classes.comparisonTotal - : classes.comparisonDetail; - - return ( -
-
{t(`income.${key}`)}
- -
- - {tone === "positive" ? "+" : ""} - - {formatMoney(money)} - - - {!!percentage && ( - - {percentage} - - )} -
-
- ); - })} -
-
- ); -}; - -function differenceTone(value: string): "negative" - | "neutral" - | "positive" { - if (!/[1-9]/.test(value)) return "neutral"; - - return value.startsWith("-") ? "negative" : "positive"; -} - -export { IncomeComparisons }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx index a02faa5..f1e20fa 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx @@ -1,6 +1,6 @@ import type { FC, SubmitEventHandler } from "react"; -import { Button, Paper, Stack, Text } from "@mantine/core"; +import { Button, Stack } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import classes from "../../AccountIncomePage.module.css"; @@ -27,15 +27,8 @@ const IncomePeriodForm: FC = (props) => { } = useIncomePeriodFormVm(); return ( - +
- {t("income.period")}
= (props) => {
- +
); }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index bf09049..968c1b9 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -492,6 +492,20 @@ describe("AccountOperationsPage", () => { }), ).toBeInTheDocument(); }); + + it("keeps a null operations envelope loading instead of a confirmed empty period", async () => { + configureTestAuth(); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => + HttpResponse.json(resource(null)), + ), + ); + renderPage(); + expect( + await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }), + ).toBeVisible(); + expect(screen.queryByText("Операций нет")).not.toBeInTheDocument(); + }); }); it("keeps UTC operation boundaries in GET and source sync requests", async () => { diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index ea1bdcc..2ad05c9 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -402,6 +402,50 @@ describe("AccountPage states", () => { ).toBeInTheDocument(); }); + it("keeps a null portfolio envelope loading instead of an empty portfolio", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(resource(null)), + ), + ); + const queryClient = createTestQueryClient(); + renderAccountRoute(queryClient); + + expect( + await screen.findByRole("status", { name: "Загрузка" }, { timeout: 4000 }), + ).toBeVisible(); + expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); + expect( + queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), + ).toMatchObject({ data: null }); + }); + + it("keeps null events and income envelopes loading in the previews", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/events", () => + HttpResponse.json(resource(null)), + ), + http.get("*/api/v1/brokerage-accounts/:accountId/income-summary", () => + HttpResponse.json(resource(null)), + ), + ); + renderAccountRoute(createTestQueryClient()); + + expect( + await screen.findByText("Портфель пока пуст", undefined, { + timeout: 4000, + }), + ).toBeVisible(); + expect( + screen.getByRole("status", { name: "Загрузка будущих событий" }), + ).toBeVisible(); + expect( + screen.queryByText("На ближайшие 30 дней событий не найдено."), + ).not.toBeInTheDocument(); + expect(screen.getByText("Загрузка дохода текущего месяца")).toBeVisible(); + expect(screen.queryByText("Нет текущих сумм")).not.toBeInTheDocument(); + }); + it("renders a stale portfolio in upstream order with nullable values hidden", () => { renderPage(); @@ -686,7 +730,7 @@ describe("AccountPage states", () => { ).toBeVisible(); for (const amount of ["850,50 ₽", "0 USD", "1 000 ₽", "Сумма неизвестна"]) expect(content.getByText(amount)).toBeVisible(); - expect(content.getAllByText("Ожидаемая сумма")).toHaveLength(4); + expect(content.getAllByText("Прогноз")).toHaveLength(4); expect(content.queryByText("Без движения денег")).not.toBeInTheDocument(); expect(content.queryByText(/^\+/)).not.toBeInTheDocument(); expect( diff --git a/src/pages/bond/ui/BondPage/BondPage.test.tsx b/src/pages/bond/ui/BondPage/BondPage.test.tsx index fada25a..3c5fa52 100644 --- a/src/pages/bond/ui/BondPage/BondPage.test.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.test.tsx @@ -88,7 +88,6 @@ const quote function setup( options: { ambiguousTicker?: boolean; - failBond?: boolean; failCoupons?: boolean; noPosition?: boolean; path?: string; @@ -100,11 +99,7 @@ function setup( const resolved = { ...bond, slug: options.slug ?? uid }; let isFailQuote = false; server.use( - http.get("*/api/v1/bonds/:uid", () => - options.failBond - ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) - : HttpResponse.json(resource(resolved)), - ), + http.get("*/api/v1/bonds/:uid", () => HttpResponse.json(resource(resolved))), http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => { requests.push(new URL(request.url)); diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index b727fdf..71ab684 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -191,8 +191,10 @@ const BondPageView: FC = (props) => { diff --git a/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx index 930d0dd..70aeffb 100644 --- a/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx +++ b/src/pages/bond/ui/BondPage/components/BondCoupons.view.tsx @@ -1,17 +1,20 @@ import type { FC } from "react"; -import { Accordion, Alert, Badge, Group, Paper, Stack, Text } from "@mantine/core"; +import { Accordion, Alert, Badge, Group, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import type { BondCoupons as Coupons } from "@/entities/bond"; -import { InstrumentMetric, InstrumentSection, type SectionState } from "@/features/instrument-details"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import { InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; +import { PayoutCard } from "@/shared/ui"; type BondCouponsProps = { coupons?: Coupons; + logoName?: null | string; pastCoupons: Coupons["coupons"]; state: SectionState; + ticker?: null | string; upcoming: Coupons["coupons"]; }; @@ -39,6 +42,8 @@ const BondCoupons: FC = (props) => { )) ) @@ -75,6 +80,8 @@ const BondCoupons: FC = (props) => { )) ) @@ -91,59 +98,48 @@ const BondCoupons: FC = (props) => { type CouponProps = { coupon: Coupons["coupons"][number]; + logoName?: null | string; + ticker?: null | string; }; const Coupon: FC = (props) => { const { coupon } = props; const { t } = useTranslation(); + const title = `${t("events.type.coupon")} · ${props.ticker || t("bond.instrument")}`; + + const metadata = [ + coupon.paymentDate ? formatDate(coupon.paymentDate) : t("stock.dateUnknown"), + t("bond.couponNumber", { number: coupon.number ?? t("bond.unknown") }), + ].join(" · "); + + const conditions = ( + + + {t(`bond.couponTypes.${coupon.couponType}`, { defaultValue: t("bond.unknown") })} + + + {coupon.periodDays != undefined && ( + {t("bond.couponDays", { count: coupon.periodDays })} + )} + + {coupon.recordDate && ( + {t("stock.record")}: {formatDate(coupon.recordDate)} + )} + + ); return ( - - -
- - {coupon.paymentDate - ? formatDate(coupon.paymentDate) - : t("stock.dateUnknown")} - - - - {t("bond.couponNumber", { - number: coupon.number ?? t("bond.unknown"), - })} - -
- - - - {!!coupon.estimatedAmount && ( - - )} -
- - - - {t(`bond.couponTypes.${coupon.couponType}`, { - defaultValue: t("bond.unknown"), - })} - - - {coupon.periodDays != undefined && ( - - {t("bond.couponDays", { count: coupon.periodDays })} - - )} - - {!!coupon.recordDate && ( - - {t("stock.record")}: {formatDate(coupon.recordDate)} - - )} - -
+ ); }; diff --git a/src/pages/card/ui/CardPage/CardHistory.view.tsx b/src/pages/card/ui/CardPage/CardHistory.view.tsx index 5111a95..43870d9 100644 --- a/src/pages/card/ui/CardPage/CardHistory.view.tsx +++ b/src/pages/card/ui/CardPage/CardHistory.view.tsx @@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { + BankFeedback, bankOperationAppearance, } from "@/entities/bank-card"; @@ -108,6 +109,17 @@ const CardHistory: FC = (props) => { return {t("bank.noOperations")}; } + if (vm.history.error) { + return ( + + ); + } + if (vm.history.loading) { return ( diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index 467d35a..17df568 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -316,12 +316,47 @@ describe("CardPage", () => { setup({ summaryError: true }); await screen.findByRole("heading", { name: "Повседневная" }); expect( - screen.queryByRole("alert"), - ).not.toBeInTheDocument(); + await screen.findByText("Перевод на «Запасная»"), + ).toBeInTheDocument(); + expect(await screen.findByRole("alert", {}, { timeout: 5000 })).toHaveTextContent( + "Не удалось загрузить данные карт.", + ); + expect(screen.queryByText("0 ₽")).not.toBeInTheDocument(); + }); + + it("recovers summary with retry while keeping card and journal", async () => { + const { summaryRequests } = setup({ summaryError: true }); + await screen.findByRole("heading", { name: "Повседневная" }); expect( await screen.findByText("Перевод на «Запасная»"), ).toBeInTheDocument(); - expect(screen.queryByText("0 ₽")).not.toBeInTheDocument(); + + const retry = await screen.findByRole( + "button", + { name: "Повторить" }, + { timeout: 5000 }, + ); + + const requestsBefore = summaryRequests.length; + server.use( + http.get("*/api/v1/bank-cards/summary", ({ request }) => { + summaryRequests.push(new URL(request.url).search); + + return HttpResponse.json({ + cardId: "a", + categories: [], + expenses: money("0"), + net: money("-50"), + refunds: money("50"), + }); + }), + ); + fireEvent.click(retry); + + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect(summaryRequests).toHaveLength(requestsBefore + 1); }); it("keeps cached card and history after refetch failure", async () => { const { client } = setup(); @@ -331,9 +366,8 @@ describe("CardPage", () => { http.get("*/api/v1/bank-cards/a/operations", () => HttpResponse.error()), ); await client.invalidateQueries(); - await waitFor(() => { - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); + const alerts = await screen.findAllByRole("alert", {}, { timeout: 5000 }); + expect(alerts.length).toBeGreaterThanOrEqual(1); expect( screen.getByRole("heading", { name: "Повседневная" }), ).toBeInTheDocument(); @@ -401,11 +435,11 @@ describe("CardPage", () => { ]); }); it("recovers the prerequisite card list while preserving the visible balance and journal", async () => { - setup({ cardsError: true }); + const { client } = setup({ cardsError: true }); await screen.findByText("Перевод на «Запасная»"); expect( - screen.queryByRole("alert"), - ).not.toBeInTheDocument(); + await screen.findByRole("alert", {}, { timeout: 5000 }), + ).toHaveTextContent("Не удалось загрузить данные карт."); expect( screen.getByRole("button", { name: "Добавить операцию" }), ).toBeDisabled(); @@ -414,6 +448,7 @@ describe("CardPage", () => { HttpResponse.json([card, { ...card, id: "b", name: "Запасная" }]), ), ); + await client.invalidateQueries(); await waitFor(() => expect( screen.getByRole("button", { name: "Добавить операцию" }), diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx index 4a499d9..df534f1 100644 --- a/src/pages/card/ui/CardPage/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage/CardPage.view.tsx @@ -75,6 +75,11 @@ const CardPageView: FC = (props) => { {t("bank.back")} + {!vm.notFound && ( + + )} )} error @@ -128,18 +133,27 @@ const CardPageView: FC = (props) => { )} {card.archivedAt && {t("bank.archiveHint")}} + + {vm.cardError && vm.card && ( + + )} {vm.cardsFeedback && ( )} - + @@ -183,8 +197,6 @@ const CardPageView: FC = (props) => { - {vm.history.error && !vm.rows && {t("bank.error")}} - {vm.history.page !== undefined && vm.pageSize !== "all" && ( card.refetch(), cards: cards.data, cardsFeedback: cards.isError ? { cached: Boolean(cards.data), error: cards.error } : undefined, + cardsRetry: () => cards.refetch(), closeDeletion: () => setDeleting(null), closeEditing: () => setEditingCard(false), closeOperation: () => setOperation(null), @@ -112,9 +115,10 @@ function useCardPageVm() { editingCard, history: { changingPage: history.isPlaceholderData, - error: history.isError, + error: history.error, loading: history.isLoading, page: history.data?.page, + refetch: () => history.refetch(), }, notFound: isApiErrorCode( apiErrorCode(card.error), @@ -139,9 +143,10 @@ function useCardPageVm() { }, summary: { data: summary.data, - error: summary.isError, + error: summary.error, fetching: summary.isFetching, loading: summary.isLoading, + refetch: () => summary.refetch(), }, toggleArchive: () => { if (!card.data) return; diff --git a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx index 8856550..12af7b8 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.test.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.test.tsx @@ -47,8 +47,9 @@ function renderCards(isFail = false, hasCategoryFailure = false) { ), ); - return render( - + const client = createTestQueryClient(); + render( + @@ -56,6 +57,8 @@ function renderCards(isFail = false, hasCategoryFailure = false) { , ); + + return client; } describe("CardsPage", () => { @@ -232,4 +235,76 @@ describe("CardsPage", () => { }); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); + + it("shows a cold cards error with retry", async () => { + renderCards(true); + + expect( + await screen.findByRole("alert", {}, { timeout: 5000 }), + ).toHaveTextContent("Не удалось загрузить данные карт."); + expect( + screen.getByRole("button", { name: "Повторить" }), + ).toBeInTheDocument(); + + server.use( + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([ + { + archivedAt: null, + balance: money("10000"), + bankName: null, + id: "a", + lastFour: null, + name: "Повседневная", + openingBalance: money("10000"), + openingDate: "2026-01-01", + version: 1, + }, + ]), + ), + ); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ); + expect( + screen.getByRole("link", { name: /Повседневная/ }), + ).toBeInTheDocument(); + }); + + it("shows a cached cards error while keeping the grid", async () => { + const view = renderCards(); + + await screen.findByRole("heading", { name: "Добавьте первую карту" }); + server.use( + http.get("*/api/v1/bank-cards", () => + HttpResponse.json([ + { + archivedAt: null, + balance: money("10000"), + bankName: null, + id: "a", + lastFour: null, + name: "Повседневная", + openingBalance: money("10000"), + openingDate: "2026-01-01", + version: 1, + }, + ]), + ), + ); + await view.invalidateQueries(); + await screen.findByRole("link", { name: /Повседневная/ }); + + server.use(http.get("*/api/v1/bank-cards", () => HttpResponse.error())); + await view.invalidateQueries(); + + expect( + await screen.findByRole("alert", {}, { timeout: 5000 }), + ).toHaveTextContent("Не удалось обновить данные. Показаны последние полученные."); + expect( + screen.getByRole("link", { name: /Повседневная/ }), + ).toBeInTheDocument(); + }); }); diff --git a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx index 1ad94dd..ecc07bc 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.view.tsx +++ b/src/pages/cards/ui/CardsPage/CardsPage.view.tsx @@ -18,6 +18,7 @@ import { Link } from "react-router"; import { BankCardFace, + BankFeedback, BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; @@ -51,15 +52,17 @@ const CardsPageViewCards: FC = (props) => { ); } - const text = t(vm.archive ? "bank.emptyHint" : "bank.noActive"); + if (!vm.cards.error) { + const text = t(vm.archive ? "bank.emptyHint" : "bank.noActive"); - return ( - {t("bank.addCard")}} - text={text} - title={t("bank.empty")} - /> - ); + return ( + {t("bank.addCard")}} + text={text} + title={t("bank.empty")} + /> + ); + } } if (vm.cards.isLoading) { @@ -103,6 +106,15 @@ const CardsPageView: FC = (props) => { onChange={vm.onArchiveChange} /> + {vm.cards.error && ( + + )} + @@ -123,9 +135,10 @@ const CardsPageView: FC = (props) => { ); diff --git a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts index 20b00aa..297faf0 100644 --- a/src/pages/cards/ui/CardsPage/CardsPage.vm.ts +++ b/src/pages/cards/ui/CardsPage/CardsPage.vm.ts @@ -39,6 +39,8 @@ function useCardsPageVm() { setArchive(event.currentTarget.checked), onCategories: () => setManagingCategories(true), period, + retryCards: () => cards.refetch(), + retrySummary: () => summary.refetch(), setPeriod: (value: [null | string, null | string]) => { if (value[0] && value[1]) setParameters({ from: value[0], to: value[1] }); }, diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index e0d5c46..720ad65 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -337,7 +337,7 @@ describe("stock page", () => { "/stocks/SBERP?period=year&events=trades", ), ); - expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); + expect(await screen.findByText("Дивиденд")).toBeVisible(); }); it("offers search for ambiguous tickers without requesting personal data", async () => { const { requests } = setup({ ambiguousTicker: true, path: "/stocks/SAME" }); @@ -368,18 +368,18 @@ describe("stock page", () => { screen.getByText("К закрытию предыдущего торгового дня, 09.09.2026"), ).toBeVisible(); expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); - expect(screen.getByText("Событие компании")).toBeVisible(); + expect(screen.getAllByText("на акцию").length).toBeGreaterThan(0); expect(screen.getByText("328,8 ₽")).toBeVisible(); expect(screen.getByText("P/E · TTM")).toBeVisible(); expect(screen.getByText("Описание компании")).toBeVisible(); expect( screen.queryByRole("button", { name: "О компании" }), ).not.toBeInTheDocument(); - expect(screen.getByText("13.10.2026")).toBeVisible(); + expect(screen.getByText(/13\.10\.2026/)).toBeVisible(); fireEvent.click( screen.getByRole("button", { name: "История дивидендов компании" }), ); - await waitFor(() => expect(screen.getByText("13.10.2025")).toBeVisible()); + await waitFor(() => expect(screen.getByText(/13\.10\.2025/)).toBeVisible()); expect(screen.getByText("Текущая средняя покупка: 200 ₽")).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ })); const rows = within(await screen.findByRole("table")).getAllByRole("row"); diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 28d963b..c0929ad 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -63,8 +63,10 @@ const StockPageView: FC = (props) => { diff --git a/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx index ac54370..2f576e5 100644 --- a/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx @@ -1,198 +1,96 @@ import type { FC } from "react"; -import { - Accordion, - Alert, - Badge, - Group, - Paper, - Stack, - Text, -} from "@mantine/core"; +import { Accordion, Alert, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import type { StockDividends as Dividends } from "@/entities/stock"; import { InstrumentSection, type SectionState } from "@/features/instrument-details"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import { formatDate, formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib"; +import { PayoutCard } from "@/shared/ui"; -import classes from "../StockPage.module.css"; +type Dividend = Dividends["dividends"][number]; + +type DividendCardProps = { + event: Dividend; + logoName?: null | string; + ticker?: null | string; +}; type StockDividendsProps = { dividends?: Dividends; + logoName?: null | string; pastDividends: Dividends["dividends"]; state: SectionState; + ticker?: null | string; upcoming: Dividends["dividends"]; }; +const DividendCard: FC = (props) => { + const { event } = props; + const { t } = useTranslation(); + const title = `${t("events.type.dividend")} · ${props.ticker || t("stock.share")}`; + + const metadata = [ + event.recordDate + ? `${t("stock.record")}: ${formatDate(event.recordDate)}` + : t("stock.dateUnknown"), + event.paymentDate ? `${t("stock.payment")}: ${formatDate(event.paymentDate)}` : null, + ].filter(Boolean).join(" · "); + + const conditions = event.lastBuyDate + ? {t("stock.lastBuy")}: {formatDate(event.lastBuyDate)} + : undefined; + + return ( + + ); +}; + const StockDividends: FC = (props) => { const { dividends, pastDividends, state, upcoming } = props; const { t } = useTranslation(); - let upcomingContent; - - if (dividends) { - if (upcoming.length > 0) { - upcomingContent = ( - upcoming.map(event => ( -
- -
- - {t("stock.companyEvent")} - - - - {formatMoney(event.perShare)} - {" "} - - {t("stock.perShare")} - - -
- - {!!event.estimatedAmount && ( -
- - {t("stock.yourEstimate")} - - - - {formatMoney(event.estimatedAmount)} - -
- )} -
- - - {!!event.lastBuyDate && ( -
- - {t("stock.lastBuy")} - - - {formatDate(event.lastBuyDate)} -
- )} - - {!!event.recordDate && ( -
- - {t("stock.record")} - - - {formatDate(event.recordDate)} -
- )} - - {!!event.paymentDate && ( -
- - {t("stock.payment")} - - - {formatDate(event.paymentDate)} -
- )} -
-
- )) - ); - } - else { - upcomingContent = ( - - {t("stock.noUpcoming", { to: formatDate(dividends.to) })} - - ); - } - } - - let pastDividendsContent; - - if (pastDividends.length > 0) { - pastDividendsContent = ( - - {pastDividends.map(event => ( - - -
- - {t("stock.record")} - - - - {event.recordDate - ? formatDate(event.recordDate) - : t("stock.dateUnknown")} - - - {!!event.paymentDate && ( - - {t("stock.payment")}:{" "} - {formatDate(event.paymentDate)} - - )} -
- -
- - {formatMoney(event.perShare)} - - - - {t("stock.perShare")} - -
-
-
- ))} -
- ); - } - else { - pastDividendsContent = {t("stock.noDividends")}; - } - return ( {!!dividends && ( - {!!( - dividends.estimateUnavailable || dividends.positionIsStale - ) && ( + {(dividends.estimateUnavailable || dividends.positionIsStale) && ( {t("stock.estimateUnavailable")} )} - {upcomingContent} + {upcoming.length > 0 + ? upcoming.map(event => ( + + )) + : {t("stock.noUpcoming", { to: formatDate(dividends.to) })}} - - {t("stock.dividendNote")} - + {t("stock.dividendNote")} {!!dividends.positionAt && ( - {t("stock.snapshotAt", { - date: formatSnapshotTime(dividends.positionAt), - })} + {t("stock.snapshotAt", { date: formatSnapshotTime(dividends.positionAt) })} )} @@ -210,7 +108,24 @@ const StockDividends: FC = (props) => { })} - {pastDividendsContent} + {pastDividends.length > 0 + ? ( + + {pastDividends.map(event => ( + + ))} + + ) + : {t("stock.noDividends")}} diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx index d36d5b0..76718f6 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx +++ b/src/pages/stocks/ui/StocksPage/StocksPage.test.tsx @@ -1,6 +1,6 @@ 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 { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -12,19 +12,37 @@ import { server } from "@/test/server"; import { StocksPage } from "./StocksPage"; import { useStocksPageVm } from "./StocksPage.vm"; +const pageOperations: Promise[] = []; + const StocksPageVmProbe = () => { const vm = useStocksPageVm(); return ( <> - - + + {vm.items.map(item => item.name).join(", ")} ); }; -afterEach(() => vi.useRealTimers()); +afterEach(() => { + pageOperations.length = 0; + + return vi.useRealTimers(); +}); describe("stock search", () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => { @@ -230,9 +248,12 @@ describe("stock search", () => { fireEvent.click(screen.getByRole("button", { name: "page" })); fireEvent.click(screen.getByRole("button", { name: "search" })); await screen.findByText("Новый"); - oldPage.resolve(); + await act(async () => { + oldPage.resolve(); + await pageOperations[0]; + }); - await waitFor(() => expect(screen.getByText("Новый")).toBeVisible()); + expect(screen.getByText("Новый")).toBeVisible(); expect(screen.queryByText("Старый результат")).not.toBeInTheDocument(); }); diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index 8ce87d7..81f6986 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -101,6 +101,7 @@ function useStocksPageVm() { }; return { + changePage, criteria, error, items: page?.items ?? [], @@ -111,6 +112,8 @@ function useStocksPageVm() { onSearch: () => void search(), page: page?.page, resultTime: page?.asOf, + // Test seam: settle the actual operation instead of the handler release. + search, searched: page !== null, totalPages: page ? page.page + Number(page.hasNext) : 0, }; diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index db15880..476cb17 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -281,6 +281,33 @@ describe("apiClient transient read recovery", () => { }, ); + it("does not retry a read refused with INVALID_TBANK_TOKEN", async () => { + let requests = 0; + server.use( + http.get("*/api/v1/brokerage-accounts", () => { + requests += 1; + + return HttpResponse.json( + { code: "INVALID_TBANK_TOKEN", status: 400 }, + { status: 400 }, + ); + }), + ); + configureAuthClient({ + accessToken: () => "token", + clear: vi.fn(), + refresh: () => Promise.resolve(false), + }); + + const { error, response } = await apiClient.GET( + "/api/v1/brokerage-accounts", + ); + + expect(response.status).toBe(400); + expect(error).toMatchObject({ code: "INVALID_TBANK_TOKEN" }); + expect(requests).toBe(1); + }); + it("does not retry mutations", async () => { let requests = 0; server.use( diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index b8bfffb..ba8d0ce 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -746,10 +746,13 @@ export interface components { detail: string; }[]; }; - CredentialsDto: { + RegisterDto: { /** @example user@example.com */ email: string; - /** @example a secure password */ + /** + * @description Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized. + * @example a secure password + */ password: string; }; UserDto: { @@ -760,6 +763,15 @@ export interface components { accessToken: string; user: components["schemas"]["UserDto"]; }; + LoginDto: { + /** @example user@example.com */ + email: string; + /** + * @description Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized. + * @example a secure password + */ + password: string; + }; MoneyDto: { currency: string; value: string; @@ -1107,16 +1119,6 @@ export interface components { data: components["schemas"]["IncomeSummaryDto"] | null; meta: components["schemas"]["InvestmentResourceMetaDto"]; }; - IncomeComparisonCurrencyDto: { - currency: string; - selected: components["schemas"]["IncomeCurrencySummaryDto"]; - compared: components["schemas"]["IncomeCurrencySummaryDto"]; - difference: components["schemas"]["IncomeCurrencySummaryDto"]; - }; - IncomeComparisonDto: { - period: components["schemas"]["IncomePeriodDto"]; - totalsByCurrency: components["schemas"]["IncomeComparisonCurrencyDto"][]; - }; IncomeBucketDto: { period: components["schemas"]["IncomePeriodDto"]; totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; @@ -1160,8 +1162,6 @@ export interface components { totalsByCurrency: components["schemas"]["IncomeCurrencySummaryDto"][]; /** Format: date-time */ calculatedAt: string; - previous: components["schemas"]["IncomeComparisonDto"] | null; - yearComparison: components["schemas"]["IncomeComparisonDto"] | null; /** @enum {string} */ bucketGranularity: "day" | "week" | "month"; buckets: components["schemas"]["IncomeBucketDto"][]; @@ -1183,7 +1183,7 @@ export interface components { lastSuccessfulAt: string | null; lastAttempt: components["schemas"]["InvestmentAttemptDto"]; /** @enum {string} */ - role: "selected" | "previous" | "year"; + role: "selected"; }; IncomeAnalyticsMetaDto: { dataRevision: string; @@ -1720,7 +1720,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CredentialsDto"]; + "application/json": components["schemas"]["RegisterDto"]; }; }; responses: { @@ -1788,7 +1788,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["CredentialsDto"]; + "application/json": components["schemas"]["LoginDto"]; }; }; responses: { diff --git a/src/shared/api/resource-query.test.tsx b/src/shared/api/resource-query.test.tsx index 5bb2f07..a6ddf93 100644 --- a/src/shared/api/resource-query.test.tsx +++ b/src/shared/api/resource-query.test.tsx @@ -164,10 +164,16 @@ describe("persistent resource query lifecycle", () => { }); await waitFor(() => expect(isStarted).toBe(true)); - await act(() => result.current.observe()); + // Capture the observation promise before releasing the old handler; the + // final assertion must follow its settlement, not the handler release. + let observation: Promise = Promise.resolve(); + await act(async () => { + observation = result.current.observe() ?? Promise.resolve(); + await observation; + }); await act(async () => { release(); - await blocked; + await observation; }); expect(result.current.data).toEqual(snapshot); }); diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index d372b0b..8ed9d81 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -10,7 +10,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CredentialsDto" + "$ref": "#/components/schemas/RegisterDto" } } } @@ -91,7 +91,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CredentialsDto" + "$ref": "#/components/schemas/LoginDto" } } } @@ -5236,7 +5236,7 @@ "code" ] }, - "CredentialsDto": { + "RegisterDto": { "type": "object", "properties": { "email": { @@ -5245,8 +5245,8 @@ }, "password": { "type": "string", - "minLength": 12, - "maxLength": 72, + "description": "Raw password: 8-72 printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.", + "pattern": "^[\\x20-\\x7E]{8,72}$", "example": "a secure password" } }, @@ -5285,6 +5285,24 @@ "user" ] }, + "LoginDto": { + "type": "object", + "properties": { + "email": { + "type": "string", + "example": "user@example.com" + }, + "password": { + "type": "string", + "description": "Raw password, at most 72 UTF-8 bytes. Not trimmed or normalized.", + "example": "a secure password" + } + }, + "required": [ + "email", + "password" + ] + }, "MoneyDto": { "type": "object", "properties": { @@ -6706,47 +6724,6 @@ "meta" ] }, - "IncomeComparisonCurrencyDto": { - "type": "object", - "properties": { - "currency": { - "type": "string" - }, - "selected": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - }, - "compared": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - }, - "difference": { - "$ref": "#/components/schemas/IncomeCurrencySummaryDto" - } - }, - "required": [ - "currency", - "selected", - "compared", - "difference" - ] - }, - "IncomeComparisonDto": { - "type": "object", - "properties": { - "period": { - "$ref": "#/components/schemas/IncomePeriodDto" - }, - "totalsByCurrency": { - "type": "array", - "items": { - "$ref": "#/components/schemas/IncomeComparisonCurrencyDto" - } - } - }, - "required": [ - "period", - "totalsByCurrency" - ] - }, "IncomeBucketDto": { "type": "object", "properties": { @@ -6948,24 +6925,6 @@ "type": "string", "format": "date-time" }, - "previous": { - "nullable": true, - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/IncomeComparisonDto" - } - ] - }, - "yearComparison": { - "nullable": true, - "type": "object", - "allOf": [ - { - "$ref": "#/components/schemas/IncomeComparisonDto" - } - ] - }, "bucketGranularity": { "type": "string", "enum": [ @@ -6998,8 +6957,6 @@ "period", "totalsByCurrency", "calculatedAt", - "previous", - "yearComparison", "bucketGranularity", "buckets", "instruments", @@ -7056,9 +7013,7 @@ "role": { "type": "string", "enum": [ - "selected", - "previous", - "year" + "selected" ] } }, @@ -7523,7 +7478,8 @@ "type": "integer", "minimum": 1 } - } + }, + "additionalProperties": false }, "StockScreenerItemDto": { "type": "object", @@ -7569,7 +7525,8 @@ }, "lot": { "type": "integer", - "nullable": true + "nullable": true, + "minimum": 1 } }, "required": [ diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 4883bac..2f7d165 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -55,8 +55,9 @@ const ru = { requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.", validation: { invalidEmail: "Некорректный email", - invalidPasswordLength: "Пароль должен содержать от 12 до 72 байт", + invalidPasswordLength: "Пароль: от 8 до 72 символов, только латинские буквы, цифры, спецсимволы и пробел", passwordsDoNotMatch: "Пароли не совпадают", + passwordTooLong: "Пароль не должен превышать 72 байта", }, }, bank: { @@ -340,13 +341,8 @@ const ru = { }, income: { apply: "Применить", - comparisonEmpty: "За эти периоды купоны и дивиденды не найдены.", - comparisonFailed: - "Не удалось загрузить период сравнения. Повторите обновление.", - comparisonPending: "Период сравнения ещё не загружен полностью.", coupon: "Купоны", datePlaceholder: "ДД.ММ.ГГГГ", - difference: "Разница", dividend: "Дивиденды", emptyText: "За выбранный период подходящих исполненных выплат нет.", emptyTitle: "Купоны и дивиденды не найдены", @@ -356,8 +352,6 @@ const ru = { open: "Открыть пассивный доход", payments: "Выплаты", period: "Период", - previousPeriod: "Предыдущий период", - previousUnavailable: "Сравнение с предыдущим периодом недоступно.", refreshError: "Не удалось обновить пассивный доход. Показан последний загруженный результат.", refreshing: "Обновляем пассивный доход", @@ -378,8 +372,6 @@ const ru = { period: "Укажите корректный период без будущих дат: начало не позже окончания.", }, - yearAgo: "Годом ранее", - yearUnavailable: "Сравнение с годом ранее недоступно.", }, investment: { attempt: { @@ -459,6 +451,14 @@ const ru = { stocks: "Акции", }, notFound: { action: "К счетам", title: "Страница не найдена" }, + payout: { + estimate: "Оценка по позиции", + executedAt: "Исполнено {{date}}", + forecast: "Прогноз", + noAmount: "Сумма неизвестна", + noMoney: "Без движения денег", + registryDate: "Дата реестра", + }, portfolio: { accountUnavailableText: "Вернитесь к списку и выберите доступный счет.", accountUnavailableTitle: "Счет недоступен", diff --git a/src/shared/lib/calendar.test.ts b/src/shared/lib/calendar.test.ts index 49ff20e..c0a017b 100644 --- a/src/shared/lib/calendar.test.ts +++ b/src/shared/lib/calendar.test.ts @@ -38,11 +38,11 @@ describe("calendar dates", () => { expect(moscowDate(new Date("2026-12-31T21:00:00Z"))).toBe("2027-01-01"); }); - it("defaults to the last 7 days including today", () => { + it("defaults to 7 calendar dates starting with today", () => { const [from, to] = defaultPeriod(); expect(calendarDate.safeParse(from).success).toBe(true); - expect(to).toBe(moscowDate(new Date())); + expect(from).toBe(moscowDate(new Date())); expect((Date.parse(to) - Date.parse(from)) / 86_400_000).toBe(6); expect(from <= to).toBe(true); }); diff --git a/src/shared/lib/calendar.ts b/src/shared/lib/calendar.ts index 64b98e1..2b9681a 100644 --- a/src/shared/lib/calendar.ts +++ b/src/shared/lib/calendar.ts @@ -9,8 +9,7 @@ function isValidPeriod(from: null | string, to: null | string) { ); } -const DAY_MS = 86_400_000; -const DEFAULT_PERIOD_DAYS = 6; +const DEFAULT_PERIOD_LAST_INDEX = 6; const moscowFormatter = new Intl.DateTimeFormat("en-CA", { day: "2-digit", @@ -25,12 +24,17 @@ function compareDateDesc(a: null | string, b: null | string) { return (b ?? "").localeCompare(a ?? ""); } -// Last 7 days including today. An empty period always means this range. +// 7 calendar dates starting with today (Moscow calendar arithmetic). function defaultPeriod(): [string, string] { - const to = new Date(); - const from = new Date(to.getTime() - DEFAULT_PERIOD_DAYS * DAY_MS); + const from = moscowDate(new Date()); - return [moscowDate(from), moscowDate(to)]; + const [year = 1970, month = 0, day = 1] = from.split("-").map(Number); + const noon = 12; + const lastDay = day + DEFAULT_PERIOD_LAST_INDEX; + const end = new Date(Date.UTC(year, month - 1, lastDay, noon)); + const to = moscowDate(end); + + return [from, to]; } function moscowDate(value: Date): string { diff --git a/src/shared/ui/PayoutCard/PayoutCard.test.tsx b/src/shared/ui/PayoutCard/PayoutCard.test.tsx new file mode 100644 index 0000000..d1803ea --- /dev/null +++ b/src/shared/ui/PayoutCard/PayoutCard.test.tsx @@ -0,0 +1,85 @@ +import type { FC } from "react"; + +import { MantineProvider } from "@mantine/core"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { PayoutCard } from "./PayoutCard"; + +const Wrapper: FC<{ children: React.ReactNode }> = props => ( + {props.children} +); + +describe("PayoutCard", () => { + it("renders an executed payout with signed source money", () => { + render( + + + , + ); + expect(screen.getByText("−10,25 ₽")).toBeInTheDocument(); + expect(screen.getByText(/Исполнено 01.08.2026 12:00/)).toBeInTheDocument(); + }); + + it("keeps zero distinct from an unknown executed amount", () => { + render( + + + , + ); + expect(screen.getByText("0,000 ₽")).toBeInTheDocument(); + expect(screen.getByText("Без движения денег")).toBeInTheDocument(); + }); + + it("marks a forecast payout and a dividend registry date without inventing an amount", () => { + render( + + + , + ); + expect(screen.getByText("Прогноз")).toBeInTheDocument(); + expect(screen.getByText(/15.08.2026 · Дата реестра/)).toBeInTheDocument(); + expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument(); + }); + + it("shows a schedule payout with per-security label and separate estimate", () => { + render( + + + , + ); + expect(screen.getByText("12,5 ₽")).toBeInTheDocument(); + expect(screen.getByText("На облигацию")).toBeInTheDocument(); + expect(screen.getByText("Оценка по позиции")).toBeInTheDocument(); + expect(screen.getByText("125 ₽")).toBeInTheDocument(); + }); +}); diff --git a/src/shared/ui/PayoutCard/PayoutCard.tsx b/src/shared/ui/PayoutCard/PayoutCard.tsx new file mode 100644 index 0000000..97745f4 --- /dev/null +++ b/src/shared/ui/PayoutCard/PayoutCard.tsx @@ -0,0 +1,166 @@ +import type { FC, ReactNode } from "react"; + +import { Group, Text } from "@mantine/core"; +import { EntryCard } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; + +import { formatMoney } from "@/shared/lib"; + +type ExecutedPayout = { + details?: ReactNode; + extraMetadata?: string; + imageSrc?: null | string; + initial: string; + kind: "executed"; + occurredAt: string; + payment: null | PayoutMoney; + title: string; + typeLabel: string; + wrap?: boolean; +}; + +type ForecastPayout = { + date: string; + dateKind?: "event" | "registry"; + extraMetadata?: string; + imageSrc?: null | string; + initial: string; + kind: "forecast"; + projectedAmount: null | PayoutMoney; + title: string; +}; + +type PayoutCardProps + = | ExecutedPayout + | ForecastPayout + | SchedulePayout; + +type PayoutMoney = { currency: string; value: string }; + +type PayoutTone + = | "negative" + | "neutral" + | "positive"; + +type SchedulePayout = { + conditions?: ReactNode; + estimate: null | PayoutMoney; + imageSrc?: null | string; + initial: string; + kind: "schedule"; + metadata: string; + perSecurity: null | PayoutMoney; + perSecurityLabel: string; + title: string; +}; + +const PayoutCard: FC = (props) => { + const { t } = useTranslation(); + + const common = { + imageSrc: props.imageSrc, + initial: props.initial, + title: props.title, + wrap: true, + }; + + if (props.kind === "executed") { + const payment = props.payment; + const presentation = executedPresentation(payment); + const isKnown = payment !== null && !isZeroDecimal(payment.value); + + const metadata = [ + t("payout.executedAt", { date: props.occurredAt }), + props.extraMetadata, + ].filter(Boolean).join(" · "); + + const valueLabel = isKnown ? props.typeLabel : t("payout.noMoney"); + + return ( + + ); + } + + if (props.kind === "forecast") { + const metadata = [ + props.date, + props.dateKind === "registry" ? t("payout.registryDate") : null, + props.extraMetadata, + ].filter(Boolean).join(" · "); + + const value = props.projectedAmount + ? formatMoney(props.projectedAmount) + : t("payout.noAmount"); + + return ( + + ); + } + + const details = ( + <> + {props.estimate + ? ( + + {t("payout.estimate")} + + {formatMoney(props.estimate)} + + ) + : null} + {props.conditions} + + ); + + const value = props.perSecurity + ? formatMoney(props.perSecurity) + : t("payout.noAmount"); + + return ( + + ); +}; + +function executedPresentation(payment: null | PayoutMoney): { + amount: string; + tone: PayoutTone; +} { + if (!payment) return { amount: "—", tone: "neutral" }; + if (isZeroDecimal(payment.value)) + return { amount: formatMoney(payment), tone: "neutral" }; + if (payment.value.startsWith("-")) + return { + amount: `−${formatMoney({ ...payment, value: payment.value.slice(1) })}`, + tone: "negative", + }; + + return { amount: `+${formatMoney(payment)}`, tone: "positive" }; +} + +function isZeroDecimal(value: string): boolean { + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal check over a bounded Money string, with no nested repetition. + return /^-?0(?:\.0+)?$/.test(value); +} + +export { PayoutCard }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 98b77c7..e887af9 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -1 +1,2 @@ +export { PayoutCard } from "./PayoutCard/PayoutCard"; export { SourceSyncStatus } from "./SourceSyncStatus/SourceSyncStatus";