Compare commits

..
21 Commits
Author SHA1 Message Date
ksv741 5b69003516 refactor: enforce enum machine codes #18
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 9m31s
Use owner enums for known codes; preserve wire values and unknown fallbacks.

Verified npm ci and verify: 379 tests, 5 lint checks, app and Storybook builds.
2026-09-28 16:36:40 +03:00
ksv741 12c3b72bfb style: pin ternary operator formatting #18 2026-09-28 16:36:40 +03:00
ksv741 061ec708c6 chore: guard unsafe DOM HTML with ESLint #18
Enable no-unsanitized recommended rules and verify TypeScript DOM sink diagnostics.
2026-09-28 16:36:40 +03:00
ksv741 72d7835227 chore: finish frontend ESLint migration #18
Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
2026-09-28 16:36:40 +03:00
ksv741 57630102e4 chore: add typed ESLint checks #18
Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
2026-09-28 16:36:40 +03:00
ksv741 95fdc8759e ci: reduce duplicate actions and serialize checks
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m1s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m2s
2026-09-28 14:18:16 +03:00
codex-bot c1b08735e3 refactor: simplify resource queries and layout tests
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 5m51s
2026-09-27 18:53:12 +03:00
codex-bot 5e076470aa fix: resolve frontend audit findings (#39)
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m27s
2026-09-27 12:11:44 +03:00
ksv741 f6db28defa fix: resolve frontend audit findings
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 4m57s
Conventional commits / commits (pull_request) Successful in 28s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m10s
Repair investment recovery, draft preservation, date validation,
currency presentation, accessibility, and route error recovery.
Correct Docker and dependency checks and consume explicit income roles.
2026-09-27 11:38:20 +03:00
ksv741 b34c1c860a chore(release): 0.4.0
Frontend image / publish (push) Successful in 6m44s
Conventional commits / commits (pull_request) Successful in 27s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m46s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m57s
2026-09-26 22:10:01 +03:00
ksv741 a005e8b13b chore: synchronize develop before release
Conventional commits / commits (pull_request) Successful in 27s
Frontend security / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m6s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m5s
2026-09-26 21:30:58 +03:00
ksv741 4e5f564fa1 feat: show persisted investment data and sync state
Consume the provided resource-envelope API snapshot. Preserve financial snapshots during
refresh, observe synchronization without importing, and fence late responses across
account and connection changes.
2026-09-26 21:22:49 +03:00
codex-bot cd09ec698c refactor: colocate frontend component files (#35)
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m53s
2026-09-23 08:49:01 +03:00
ksv741 b987fd8e78 refactor: colocate frontend component files
Frontend security / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m10s
Conventional commits / commits (pull_request) Successful in 25s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m10s
2026-09-23 08:34:58 +03:00
ksv741 85fcfe3d07 refactor: establish local frontend workspace boundaries
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
OpenSpec change: evolve-frontend-stack
2026-09-22 22:11:02 +03:00
ksv741 c86be7bf73 fix(ui): improve financial text contrast
TD-010: use readable semantic text colors on financial surfaces while preserving signs
and state meanings.
2026-09-21 21:10:10 +03:00
ksv741 6a87379813 fix(ci): resume verification of immutable release images
TD-009: retry verification of an existing digest without republishing. Paired backend
workflow fix: 82eccc3.
2026-09-21 21:09:53 +03:00
ksv741 db184c8cae fix(api): update validation and conflict response contract
TD-003 and TD-005: explicitly deliver OpenAPI and generated types from backend commit 1667991.
2026-09-21 21:09:41 +03:00
ksv741 a209d71589 fix(auth): isolate session races and financial caches
TD-001 and TD-002: serialize cookie writes, ignore stale responses and reset financial
queries on identity changes. Backend cookie lifecycle regression: ec5780d.
2026-09-21 21:09:23 +03:00
ksv741 3c99debe88 chore(release): 0.3.0
Frontend image / publish (push) Successful in 5m45s
Conventional commits / commits (pull_request) Successful in 27s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 4m42s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m17s
2026-09-20 20:09:11 +03:00
ksv741 bb30935f73 feat: add installable PWA #30
Conventional commits / commits (pull_request) Successful in 30s
Frontend security / security (pull_request) Successful in 1m7s
Frontend verify / verify (pull_request) Successful in 4m48s
Frontend security / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 6m17s
2026-09-20 19:55:53 +03:00
370 changed files with 30933 additions and 14807 deletions
+21 -12
View File
@@ -51,31 +51,40 @@ Cross-slice consumer использует `@/` и `index.ts` владельца,
композиция принадлежит вышележащему owner. Existing меж-slice исключения
не являются разрешением добавлять новые.
Направление зависимостей и public imports частично проверяет
`npm run deps`; Knip проверяет использование exports, а не смысл placement.
Направление зависимостей, public imports и запрет соседних slices в
`pages`, `features`, `entities` проверяет `npm run deps`. Внутренние imports
одного slice и связи shared segments этот запрет не ограничивает.
Knip проверяет использование exports, а не смысл placement.
## UI foundation
`shared/theme/tokens.json` — переносимый source design tokens; `theme.ts`
Private workspace `packages/tokens` экспортирует переносимый source design tokens
как `@wealthledger/tokens/tokens.json`. Private workspace `packages/ui`
адаптирует доверенный JSON к Mantine без runtime schema. App и Storybook читают
public API `shared/theme`. Storybook показывает synthetic presentation states,
`@wealthledger/ui/theme` и явно подключают `@wealthledger/ui/styles.css` после
Mantine CSS. Потребители используют public exports пакетов напрямую. Storybook показывает synthetic presentation states,
не использует application API. RTL/MSW проверяют поведение приложения отдельно.
Existing route-aware AppShell расположен в shared/ui; это legacy placement,
а не образец нового shared primitive. При изменении foundation прочитай
Route-aware AppShell и его navigation styles принадлежат `app/ui`;
`ProtectedLayout` использует public API этого владельца. При изменении foundation прочитай
[UI](conventions/ui.md#визуальная-основа) и [Storybook](conventions/storybook.md).
Статичные Add/Edit/Delete и публичный `IconProps` принадлежат private
`packages/icons`; package не зависит от UI, domain или motion.
`BankOperationIcon` и mappings остаются у entity. Card/Cards используют package
Add/Edit/Delete; restore/category SVG остаются локальными.
## Примеры
- Общая busy-область: существующий [LoadingSkeleton](../src/shared/ui/LoadingSkeleton.tsx), а не domain widget в shared.
- Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage.tsx) и её flat `components/*.view.tsx`; новый page-local filter остаётся здесь.
- Общая busy-область: существующий [LoadingSkeleton](../packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx), а не domain widget в shared.
- Account-income state views: [страница](../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx) и её `components/`; новый page-local filter остаётся здесь. Комплекты файлов группируются по [UI convention](conventions/ui.md#размещение-файлов-компонента).
- Portfolio query: [entity public API](../src/entities/portfolio/index.ts); новая UI-сводка с бизнес-смыслом не становится shared только из-за нескольких экранов.
## Проверка
После placement/import change: `npm run deps`, `npm run typecheck`, `npm run knip`;
после собственного behavior — применимые tests. Финальный gate указан в AGENTS.
Existing dependency config не запрещает все same-layer imports: их и смысл
owner проверяет review. Не объявляй Steiger установленным; graph проверяет
текущий dependency-cruiser. Review отдельно проверяет смысл owner: разрешённый dependency graph не доказывает
правильный выбор слоя.
Graph `src` и `packages` проверяет текущий dependency-cruiser; зависимости
из packages в код приложения `src` запрещены. Generated `packages/*/dist`
не анализируется как исходник; все package sources остаются в graph. Review отдельно проверяет смысл
owner: разрешённый dependency graph не доказывает правильный выбор слоя.
+1 -1
View File
@@ -70,7 +70,7 @@ Network/unknown error получает безопасное локальное
Если одна группа API codes требует одинакового recovery в нескольких views,
сведи классификацию у ближайшего общего owner. Не размножай magic comparisons
и не объявляй закрытый frontend error enum: generated `code` пока `string`.
и используй `ApiErrorCode` для известных значений: generated `code` остаётся `string`.
Неизвестный новый code должен сохранять fallback. Рефакторинг существующих
разных recovery paths требует behavior proof, а не общей обработки всех ошибок.
+2 -1
View File
@@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
Переиспользуй его symbols; для machine codes и protocol values используй
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit.
+4 -1
View File
@@ -1,5 +1,8 @@
# Storybook
Единый каталог включает stories приложения и `packages/*/src`; общие
StateView/LoadingSkeleton и их stories принадлежат `packages/ui`.
## Когда нужна story
- Shared UI/theme: показывай принятые visual roles и переиспользуемое поведение.
@@ -23,7 +26,7 @@ Stories не достигают application API, auth, VM и query providers д
контракт: keyboard activation, доступность действия, local callback. Не повторяй
RTL suite и не тестируй внутренности Mantine. Существующий
[Button](../../src/shared/ui/Button.stories.tsx) показывает themed control
без production wrapper; [LoadingSkeleton](../../src/shared/ui/LoadingSkeleton.stories.tsx)
без production wrapper; [LoadingSkeleton](../../packages/ui/src/LoadingSkeleton/LoadingSkeleton.stories.tsx)
— значимые busy states.
`npm run typecheck`, `npm run deps:storybook`, `npm run build:catalog` проверяют
+1 -1
View File
@@ -45,7 +45,7 @@ Finance: loading/error/empty/loaded, cached snapshot при refetch failure, ret
отсутствие старых данных после session/connection switch. ErrorBoundary: test-only
throwing route, безопасная ссылка и reset после navigation.
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm.test.tsx),
Примеры: [AuthForm test](../../src/features/auth/ui/AuthForm/AuthForm.test.tsx),
[client test](../../src/shared/api/client.test.ts),
[usePortfolio test](../../src/entities/portfolio/api/usePortfolio.test.tsx).
Не копируй fixtures/сценарии целиком, если задача затрагивает один исход.
+15 -10
View File
@@ -5,7 +5,8 @@
- Сохраняй strict TS profile и `import type`. Для недоверенного значения используй
`unknown` с narrowing; `any` не заменяет DTO/validation. Локальное исключение
на нетипизированной boundary должно быть узким и объяснённым.
- `enum` и `namespace` не используются: frontend имеет `erasableSyntaxOnly`.
- Строковые `enum` используются для machine codes и protocol values.
`namespace` не используется; tooling сохраняет `erasableSyntaxOnly`.
Для состояния с разными payload выбирай discriminated union; не добавляй
необязательные поля, допускающие невозможные комбинации.
- Named components — const-valued `FC<Props>`; named props находятся рядом или
@@ -14,7 +15,7 @@
на основе `PropsWithChildren<{ … }>`; без props — `FC`.
- Обычные exports — нижний named block; barrel делает прямой re-export.
CSF сохраняет default meta и прямые story exports; configuration exceptions
заданы в [Biome](../../biome.json). Не распространяй их на application code.
заданы в [ESLint](../../eslint.config.mjs). Не распространяй их на application code.
## Hooks и derived state
@@ -33,19 +34,23 @@ Container/VM/view и state owners — в [UI](ui.md).
Общее правило ownership и символических значений —
[Domain values](engineering.md#domain-values).
Backend vocabulary бери из generated `paths`, не создавай ручную копию enum.
Текущий `schema.d.ts` содержит только types: typed wire literals допустимы,
когда generator не предоставляет runtime values.
Backend vocabulary бери из generated `paths`; generated artifacts вручную не меняй.
Для открытых `string` полей допускается локальный enum известных значений,
используемых UI. Он не закрывает wire contract и сохраняет unknown fallback.
Если generator предоставляет runtime enum, используй его.
`ProblemDetailsDto.code` сейчас `string`. Не объявляй закрытый frontend error
enum и не превращай неизвестный code в известный через cast. Повторяющуюся
`ProblemDetailsDto.code` сейчас `string`. `ApiErrorCode` перечисляет известные UI коды, а не закрывает wire contract.
`apiErrorCode` сохраняет произвольную строку; `isApiErrorCode` проверяет известное
значение без cast и сохраняет fallback для неизвестного кода. Повторяющуюся
обработку своди у ответственного owner; [errors](data.md#errors).
`noMagicNumbers` в Biome — error; fixtures/tests/stories исключены только
из этого правила. Policy получает имя и единицы; подавление допустимо для
`@typescript-eslint/no-magic-numbers` — error; fixtures/tests/stories исключены
из этой дополнительной проверки. Policy получает имя и единицы; подавление допустимо для
incidental value с объяснением. Обычный UI-текст не превращай в constant:
его owner — [i18n](ui.md#строки-и-валидация).
Semantic magic strings и props profile требуют review.
`no-restricted-syntax` запрещает inline machine codes и protocol values в production;
`no-unsafe-enum-comparison` проверяет сравнения enum. Fixtures/stories сохраняют
literal wire values. Новые vocabularies и props profile требуют review.
## Проверка
+56 -11
View File
@@ -12,8 +12,11 @@ Mantine component и общую тему. Для выпадающего выбо
Семантический HTML для структуры страницы остаётся уместным.
Icons ищи у существующего owner (например,
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon.tsx));
общая icon library сейчас не принята. Не добавляй пакет ради одного символа. Business/domain
[BankOperationIcon](../../src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx));
статичные Add/Edit/Delete доступны в `@wealthledger/icons`. Их SVG декоративны
по умолчанию; имя action принадлежит кнопке. Standalone meaningful icon получает
`aria-hidden={false}`, `role="img"` и доступное имя. Category/kind mappings
и анимации остаются в entity. Не добавляй пакет ради одного символа. Business/domain
components живут у feature/entity/page, даже при повторном использовании.
Wrapper нужен для проектной ответственности, а не ради нового имени
@@ -53,11 +56,34 @@ retry callbacks, domain grouping и derived presentation data. View получа
image fallback) и formatter одного переданного значения. Группировка/доли
коллекции готовятся в VM. Перенос fetch во view ради меньшего container нарушает границу.
Отдельный state/section с самостоятельной responsibility выноси в flat
`components/*.view.tsx`. Небольшой tightly-coupled renderer оставляй рядом.
Не создавай folder/barrel/test/CSS для каждого view и не дроби по line count.
Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage.tsx)
и [его VM](../../src/pages/account-income/ui/AccountIncomePage.vm.ts).
Отдельный state/section с самостоятельной responsibility выноси в
`components/` своего component. Небольшой tightly-coupled renderer оставляй
рядом; размер файла сам по себе не требует разделения.
Пример: [AccountIncomePage](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx)
и [его VM](../../src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts).
## Размещение файлов компонента
Правило действует в `src` и `packages`: компонент с сопутствующими файлами
(view, VM, types, CSS, tests, stories) размещай в папке `Component/`.
Сохраняй полные имена файлов: `Component.tsx`, `Component.view.tsx`,
`Component.test.tsx`. Создавай только нужные файлы; одиночный компонент
оставляй непосредственно в `ui`, `components` или source-каталоге пакета.
Внутренние части принадлежат папке родителя; для набора state/section views
используй её `components/`. Внутри него действует то же правило: отдельная
папка объединяет комплект файлов, одиночный view остаётся файлом.
Стили и хуки нескольких соседних компонентов остаются у ближайшего общего
владельца; не копируй их и не прячь внутрь одного потребителя.
Папка компонента не создаёт новую FSD-границу. Публичные exports остаются в
существующем `index.ts` slice/segment или entry пакета; внутри используй
прямые относительные imports без дополнительных barrels на каждый компонент.
При переносе обновляй imports, ссылки документации и проверки, зависящие от пути.
Пример: [BankCardForm](../../src/entities/bank-card/ui/BankCardForm/BankCardForm.tsx)
лежит рядом со своим view, тестом и story; общий `BankForms.module.css`
остаётся в `bank-card/ui`.
## Формы
@@ -95,7 +121,7 @@ Types, props, hooks и memoization — в [TypeScript/React](typescript-react.md
Продуктовый текст хранится в `src/shared/i18n/locales/ru.ts`, читается через
`useTranslation()`. В Zod используй `validationMessage(key)` из i18n/zod, без
пользовательских literal messages. Это mandatory semantic review: общего запрета
непереведённых JSX-строк в Biome/TypeScript нет. Synthetic story fixtures и
непереведённых JSX-строк в ESLint/TypeScript нет. Synthetic story fixtures и
описания историй допустимы; они не заменяют продуктовую локализацию.
## Проверка
@@ -107,10 +133,29 @@ loading/error/empty/loaded и recovery по применимости. [Storybook
## Визуальная основа
Общая тема живёт в `src/shared/theme`: `tokens.json` задаёт переносимые значения,
`theme.ts` адаптирует их к Mantine. Используй public API темы и её semantic roles,
без отдельной палитры в page/component. Поддерживается светлая схема.
Private workspace `packages/tokens` владеет переносимыми значениями
`tokens.json`; `packages/ui/src/theme.ts` читает export пакета
`@wealthledger/tokens/tokens.json` и адаптирует его к Mantine. Используй
`@wealthledger/ui/theme` и его semantic roles; runtime подключает
`@wealthledger/ui/styles.css` после Mantine CSS. Providers остаются у runtime.
Палитра page/component следует этим semantic roles. Поддерживается светлая схема.
Цвет дополняет подпись или знак; проверяй контраст конкретной пары text/background.
Warning не используй для мелкого текста на белом. Деньги сохраняют валюту, знак
и различие между нулём и неизвестной суммой. Общие состояния демонстрирует
Storybook; presentation stories используют synthetic data без application API.
EntryCard, StateView и LoadingSkeleton принадлежат `packages/ui`; публичный import —
`@wealthledger/ui`. Переводы, retry и формы skeleton остаются у callers.
Compatibility re-exports удалены; callers используют public export пакета.
## Modal animation
BankCardForm, OperationForm, DeleteOperation и CategoryManager монтируют Modal
с `opened=true`: Mantine Transition начинает в состоянии `entered`.
CSS `modal-appear`/`modal-backdrop-appear` в `packages/ui/src/styles.css`
обеспечивает появление при mount (220ms, только no-preference). Mantine theme
владеет exit transition (180ms), overlay blur и completion callback;
`onExitTransitionEnd` позволяет дождаться закрытия перед unmount и вернуть фокус.
Удаление CSS без изменения mount lifecycle убирает вступительную анимацию.
TbankConnection Modal использует toggle `opened`; вывод о разделении фаз
банковских форм на этот сценарий автоматически не распространяется.
+30
View File
@@ -13,3 +13,33 @@
!public/**
!docker/
!docker/**
!packages/
!packages/tokens/
!packages/tokens/package.json
!packages/tokens/tokens.json
!packages/ui/
!packages/ui/package.json
!packages/ui/vite.config.ts
!packages/ui/tsconfig.json
!packages/ui/src/
!packages/ui/src/**
!packages/icons/
!packages/icons/package.json
!packages/icons/tsconfig.json
!packages/icons/vite.config.ts
!packages/icons/src/
!packages/icons/src/index.ts
!packages/icons/src/types.ts
!packages/icons/src/svg/
!packages/icons/src/svg/AddIcon.tsx
!packages/icons/src/svg/EditIcon.tsx
!packages/icons/src/svg/DeleteIcon.tsx
# Keep local dependencies, outputs and secrets out of allowed source trees.
**/node_modules
**/dist
**/coverage
**/storybook-static
**/.env*
**/*.pem
**/*.key
-26
View File
@@ -1,26 +0,0 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
+10 -6
View File
@@ -30,6 +30,8 @@ jobs:
- run: npm ci
env:
HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- run: npm run verify
env:
KNIP_DISABLE_RAW_TRANSFER: '1'
@@ -45,15 +47,17 @@ jobs:
run: |
VERSION="${RELEASE_TAG#v}"
if docker manifest inspect "$IMAGE:$VERSION" >/dev/null 2>manifest-error.log; then
echo 'Refusing to overwrite an existing release tag'; exit 1
DIGEST="$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{.Manifest.Digest}}')"
else
if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
echo 'Cannot prove tag absence; refusing publication'; exit 1
fi
docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
fi
if ! grep -Eq 'manifest unknown|no such manifest' manifest-error.log; then
echo 'Cannot prove tag absence; refusing publication'; exit 1
fi
docker buildx build --target production --build-arg VERSION="$VERSION" --build-arg REVISION="$REVISION" --tag "$IMAGE:$VERSION" --metadata-file image-metadata.json --push .
DIGEST="$(node --input-type=commonjs -p 'require("./image-metadata.json")["containerimage.digest"]')"
docker pull "$IMAGE@$DIGEST"
test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$REVISION"
test "$(docker image inspect "$IMAGE@$DIGEST" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = "$VERSION"
docker run --rm --entrypoint sh "$IMAGE@$DIGEST" -c 'test -s /usr/share/nginx/html/index.html && nginx -t && nginx && wget -q -O /dev/null http://127.0.0.1:8080/'
printf 'Frontend image: %s@%s\n' "$IMAGE" "$DIGEST"
- name: Remove registry session
-33
View File
@@ -1,33 +0,0 @@
name: Frontend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+66 -2
View File
@@ -2,12 +2,74 @@ name: Frontend verify
on:
push:
branches:
- '**'
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened, edited]
schedule:
- cron: '17 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: frontend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
security:
needs: commits
if: >-
!cancelled() &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Verify pre-commit secret blocking and redaction
run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
verify:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
@@ -21,6 +83,8 @@ jobs:
- run: npm ci
env:
HUSKY: '0'
- name: Install Chromium and system dependencies
run: npx --no-install playwright install --with-deps chromium
- name: Run all frontend gates
run: npm run verify
env:
+1
View File
@@ -12,6 +12,7 @@ dist
storybook-static
dist-ssr
coverage
.vitest
.scannerwork
*.local
+2 -2
View File
@@ -5,11 +5,11 @@ module.exports = {
forbidden: [
...base.forbidden,
{
from: { path: String.raw`\.stories\.tsx$|^\.storybook/preview\.tsx$` },
name: "stories-must-not-reach-application-data",
severity: "error",
from: { path: "\\.stories\\.tsx$|^\\.storybook/preview\\.tsx$" },
to: {
path: "^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\\.vm\\.ts$|^src/pages/[^/]+/ui/[^/]+Page\\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/",
path: String.raw`^src/(shared/api|features/auth|app/routes|app/ui)/|^src/(entities|features)/[^/]+/api/|\.vm\.ts$|^src/pages/[^/]+/ui/.*Page\.tsx$|node_modules/(@tanstack/react-query|openapi-fetch|openapi-react-query)/`,
reachable: true,
},
},
+2 -2
View File
@@ -1,10 +1,10 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.tsx"],
addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
core: { disableTelemetry: true },
framework: "@storybook/react-vite",
stories: ["../src/**/*.stories.tsx", "../packages/*/src/**/*.stories.tsx"],
};
export default config;
+19 -16
View File
@@ -1,38 +1,32 @@
import "@mantine/core/styles.css";
import "@mantine/charts/styles.css";
import "@mantine/dates/styles.css";
import "@wealthledger/ui/styles.css";
import "dayjs/locale/ru";
import "../src/shared/i18n";
import type { Preview } from "@storybook/react-vite";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import type { Preview } from "@storybook/react-vite";
import { cssVariablesResolver, theme } from "@/shared/theme";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
const colorSchemeManager = {
clear: () => {},
get: () => "light" as const,
set: () => {},
clear: () => {},
subscribe: () => {},
unsubscribe: () => {},
};
const preview: Preview = {
parameters: {
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
controls: { expanded: true },
},
decorators: [
(Story) => (
Story => (
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light"
colorSchemeManager={colorSchemeManager}
cssVariablesResolver={cssVariablesResolver}
forceColorScheme="light"
theme={theme}
>
<DatesProvider settings={{ locale: "ru" }}>
<Story />
@@ -40,6 +34,15 @@ const preview: Preview = {
</MantineProvider>
),
],
parameters: {
controls: { expanded: true },
layout: "padded",
options: {
storySort: {
order: ["Design System", ["Foundations", "Components"], "Product"],
},
},
},
};
export default preview;
+1 -1
View File
@@ -34,4 +34,4 @@ Testing и без production diff. Перед завершением повто
| Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice |
| Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) |
| Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения |
| Versions/scripts/check scope | Manifest/lockfile, Biome/TypeScript/dependency configs |
| Versions/scripts/check scope | Manifest/lockfile, ESLint/TypeScript/dependency configs |
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
### Features
* show persisted investment data and sync state ([4e5f564](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/4e5f564fa1d583013fb3e7febdb3baa9737446b4))
### Bug Fixes
* **api:** update validation and conflict response contract ([db184c8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/db184c8caec01609e8a3df3b16097ce634e5eff0))
* **auth:** isolate session races and financial caches ([a209d71](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a209d715892862bb6d48084df0c84a10b8d56bf1))
* **ci:** resume verification of immutable release images ([6a87379](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a87379813ba1d4002779b23b2a65446b4d10258))
* **ui:** improve financial text contrast ([c86be7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c86be7bf73d0141e0606d259ada6453c0d8e3dec))
### Refactoring
* colocate frontend component files ([b987fd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b987fd8e780963dfe1dde0c714232697a52e0475))
* establish local frontend workspace boundaries ([85fcfe3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/85fcfe3d073f80e759dafb14281c9462c1d7410d))
## [0.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.2.3...v0.3.0) (2026-09-20)
### Features
* add installable PWA [#30](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/30) ([bb30935](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/bb30935f73bfcbb77c10663cf6c683d8f0caca85))
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.2.2...v0.2.3) (2026-09-19)
### Bug Fixes
+4
View File
@@ -2,10 +2,14 @@ FROM node:24.21.0-bookworm-slim AS dependencies
WORKDIR /app
ENV HUSKY=0
COPY package.json package-lock.json ./
COPY packages/tokens/package.json ./packages/tokens/
COPY packages/ui/package.json ./packages/ui/
COPY packages/icons/package.json ./packages/icons/
RUN npm ci
FROM dependencies AS development
COPY . .
RUN npm run build:packages
ENV VITE_API_BASE_URL="" API_PROXY_TARGET=http://backend:3000
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "8080", "--strictPort"]
+102 -4
View File
@@ -28,7 +28,8 @@ staged diff с полной редакцией секретов, затем за
`Dockerfile` имеет targets `development` (Vite HMR и proxy `/api/` во внутренний backend)
и `production` (nginx на 8080, SPA fallback и same-origin API proxy). Node соответствует `.nvmrc`.
Build context ограничен `.dockerignore`; `.env`, local DB и host node_modules
не попадают в image.
не попадают в image. Изменение `packages/tokens` или `packages/ui` требует rebuild development
image, если локальный запуск использует bind-mount только `src`/`public`.
```bash
docker build --target development -t wealthledger-frontend:development .
@@ -51,24 +52,91 @@ Compose topology принадлежит workspace, эксплуатационн
Проверка публикации требует настроенного registry/runner; локальный build
не доказывает package permissions.
## Установка как приложение (PWA)
Production-сборка поддерживает установку WealthLedger в отдельное окно через
`vite-plugin-pwa`. Нужен HTTPS или localhost. В Chrome/Edge выберите «Установить
WealthLedger» в меню браузера или значок установки в адресной строке; на iPhone
и iPad — Safari → «Поделиться» → «На экран Домой». Доступность установки зависит
от браузера и ОС.
Service worker кеширует только статическую оболочку, включая страницы SPA.
Вход, финансовые данные и сохранение изменений требуют сети: API, токены и
ответы API не сохраняются в Cache Storage. Это не автономный финансовый
клиент. Новая версия загружается в фоне и применяется после закрытия всех
вкладок и окон WealthLedger и повторного открытия; открытые формы автоматически
не перезагружаются.
Локальная проверка: `nvm use --silent`, `npm run build`, `npm run preview`.
В DevTools → Application проверьте Manifest, Service Workers и Cache Storage.
В режиме `npm run dev` и в Storybook service worker не включается.
## Проверка
```bash
nvm use --silent
npm ci
npx --no-install playwright install chromium
npm run verify
```
Команда проверяет, что generated OpenAPI types соответствуют committed snapshot,
затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest,
затем запускает strict TypeScript, ESLint и проверку его конфигурации,
один coverage-backed запуск Vitest,
Knip, dependency rules приложения и stories, сборки приложения и Storybook.
Отдельный CI workflow `Frontend security` на push, pull request и ежедневно
проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm run lint` запускает ESLint с `--max-warnings=0`; `npm run lint:fix`
применяет автофиксы. [Конфигурация](eslint.config.mjs) подключает рекомендованные
наборы всех установленных плагинов: TypeScript, React, Hooks, Refresh, Query,
Unicorn, import-x, SonarJS, RegExp, Security, no-unsanitized, Perfectionist, Stylistic,
Vitest, Testing Library, jest-dom и Storybook. Правила React, тестов и stories
применяются к соответствующим файлам; generated/build artifacts исключены.
Stylistic отвечает за форматирование JS/TS/TSX, Perfectionist — за сортировку.
Сохраняются табы, двойные кавычки и точки с запятой. Biome удалён; Prettier
не используется. CSS, JSON и Markdown не форматируются через ESLint.
`lint-staged` исправляет JS/TS/TSX перед commit. `lint:eslint` сначала собирает
private packages, чтобы анализ типов использовал актуальные declarations.
`npm run test:lint` проверяет действующие presets и ошибочные примеры.
`no-unsanitized` проверяет опасные DOM HTML-присваивания и вызовы;
React `dangerouslySetInnerHTML` отдельно проверяется React-плагином.
Точечные исключения в исходниках объясняют контракты и ограничения статического
анализа; изменение nullable API fields, query keys или порядка race-операций
ради линтера недопустимо. Архитектуру проверяет dependency-cruiser, поведение —
Vitest/MSW и Chromium. Линтеры не заменяют semantic/security review.
`jsx-a11y` без поддержки ESLint 10 остаётся отложенным в
[workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18).
Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless
Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup
и его заглушки layout API. Только браузерная проверка:
`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите
установку Chromium; CI устанавливает его с `--with-deps` для Linux.
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
исключений frontend нет. Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Frontend verify / commits (pull_request)`,
`Frontend verify / security (pull_request)` и
`Frontend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`.
Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого
релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения
@@ -175,3 +243,33 @@ Commits и PR titles — Conventional Commits, включая `spec:`. Верс
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
## Локальные пакеты
`packages/tokens` — private npm workspace с единственным export
`@wealthledger/tokens/tokens.json`. `packages/ui` экспортирует собранные
`@wealthledger/ui/theme` (JS + declarations) и `@wealthledger/ui/styles.css`.
Корневой export `@wealthledger/ui` содержит EntryCard, StateView и LoadingSkeleton;
Потребители импортируют компоненты напрямую из `@wealthledger/ui`.
CSS явно подключается после Mantine; providers остаются в приложении/Storybook.
Тема доступна через `@wealthledger/ui/theme`; compatibility re-exports удалены.
JSON не требует сборки. `npm ci` связывает workspaces, а существующие команды
разработки, проверок и сборки сначала выполняют `npm run build:packages`.
Library build использует отдельный Vite config без app plugins и TypeScript
для declarations. После изменения package sources перезапустите `npm run dev`
или `npm run dev:catalog`; отдельный watch-процесс пока не нужен.
Начальная версия manifest взята из приложения; отдельный release-процесс
и публикация не настроены.
Private `@wealthledger/icons` экспортирует `AddIcon`, `EditIcon`, `DeleteIcon`
и type `IconProps`. Единственный source рисунка — TSX в `packages/icons/src/svg`;
поставка содержит ESM/declarations, без raw SVG, CSS, Mantine и motion.
React — peer/external. Размер `size` по умолчанию 24, `strokeWidth` — 1.5;
цвет наследуется через `currentColor`, SVG props можно передать явно.
В action используйте декоративный `<AddIcon size={20} strokeWidth={2} />`,
доступное имя задавайте кнопке. Standalone пример:
`<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />`.
Поддерживается также стандартный `aria-labelledby`; подпись выбирает caller.
Card/Cards используют пакетные Add/Edit/Delete с прежними размерами/strokeWidth.
Restore/category SVG и предметный BankOperationIcon остаются в приложении.
+70
View File
@@ -0,0 +1,70 @@
# Security-плагины ESLint
Проверено 28 сентября 2026 года для frontend: ESLint 10.11.0,
TypeScript и React 19. Рекомендация — добавить только
`eslint-plugin-no-unsanitized@4.1.5` с полным `configs.recommended`.
Решение реализовано: пакет добавлен в devDependencies, полный recommended
подключён в ESLint. Ниже сохранены результаты исследования до установки.
## Все пакеты из раздела Security
Источник списка: [awesome-eslint / Security](https://github.com/dustinspecker/awesome-eslint#security).
Версии и даты ниже — опубликованные `latest` из npm на момент проверки.
Широкий peer range сам по себе не доказывает работоспособность.
| Пакет | Версия; публикация | Заявленный peer ESLint | Решение для frontend |
| --- | --- | --- | --- |
| [no-unsanitized](https://github.com/mozilla/eslint-plugin-no-unsanitized) | [4.1.5](https://registry.npmjs.org/eslint-plugin-no-unsanitized/4.1.5); 2026-02-19 | `^9 \|\| ^10` | Добавить: проверяет опасные присваивания и вызовы native DOM HTML API. Flat recommended включает `property` и `method` как errors. |
| [no-secrets](https://github.com/nickdeis/eslint-plugin-no-secrets) | [2.3.3](https://registry.npmjs.org/eslint-plugin-no-secrets/2.3.3); 2026-03-01 | `>=5` | Не добавлять: поиск секретов по entropy/patterns существенно пересекается с существующими Gitleaks и SonarJS. Flat config поддерживается; отсутствие рекомендации связано с дублированием. |
| [pii](https://github.com/shiva-hack/eslint-plugin-pii) | [1.0.2](https://registry.npmjs.org/eslint-plugin-pii/1.0.2); 2021-04-13 | `>=4.19.1` | Не добавлять: правило падает на ESLint 10. Проверяет email/даты рождения/IP/телефоны в литералах и комментариях, а не потоки персональных данных; потребует исключений для тестовых данных. |
| [pg](https://github.com/interlace-collie/eslint/tree/main/packages/eslint-plugin-pg) | [1.4.14](https://registry.npmjs.org/eslint-plugin-pg/1.4.14); 2026-08-06 | `^8.0.0 \|\| ^9.0.0 \|\| ^10.0.0` | Не добавлять: предназначен для Node.js с `pg`, которого во frontend нет. Пакет deprecated и переименован в `eslint-plugin-postgresql-security`; старое имя больше не обновляется. |
| [security](https://github.com/eslint-community/eslint-plugin-security) | [4.1.0](https://registry.npmjs.org/eslint-plugin-security/4.1.0); 2026-09-27 | Не указан | Уже установлен с recommended. Ориентирован главным образом на Node.js; не заменяет проверки DOM. |
| [xss](https://github.com/Rantanen/eslint-plugin-xss) | [0.1.12](https://registry.npmjs.org/eslint-plugin-xss/0.1.12); 2022-06-27 | Не указан | Не добавлять: правило падает на ESLint 10. Проверяет HTML-конкатенации/соглашения об именах и присваивания `location.href`; это не специализированная проверка React/TSX. |
## Что добавляет no-unsanitized
[Правило property](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/property.md)
проверяет присваивания `innerHTML`/`outerHTML`;
[method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/docs/rules/method.md)
проверяет, в частности, `insertAdjacentHTML`, `document.write` и `document.writeln`.
В [исходнике method](https://github.com/mozilla/eslint-plugin-no-unsanitized/blob/master/lib/rules/method.js)
также есть проверки `createContextualFragment`, `setHTMLUnsafe` и dynamic import.
Следовательно, перед включением нужно прогнать весь lint, а не только HTML-примеры.
До подключения no-unsanitized [eslint.config.mjs](eslint.config.mjs) уже включал
React-проверку `dom-no-dangerously-set-innerhtml` и security-правила SonarJS.
Предварительная проверка через локальный `ESLint.lintText` показала пробел:
функция с `element.innerHTML = html` не получала security-диагностику,
тогда как JSX `dangerouslySetInnerHTML` получал
диагностику React. Это различие в покрытии, не обнаруженная уязвимость приложения:
таких production DOM-присваиваний при поиске не найдено.
Плагин понимает TS-синтаксис через TypeScript parser, включая `as`-выражения,
но не использует TypeScript type checker. Он не доказывает безопасность всех
потоков данных. У версии 4.1.5 нет runtime dependencies; Prettier указан только
в devDependencies самого плагина и при его обычной установке потребителем
не устанавливается.
## Проверка совместимости без установки
Опубликованные npm tarballs прочитаны в `/tmp`; использованы локальные
ESLint 10.11.0 и `@typescript-eslint/parser`, без lifecycle-скриптов пакетов.
Через `Linter.verify` с recommended no-unsanitized проверены:
- `innerHTML`/`outerHTML` с параметром `html: string` — обе ошибки найдены;
- `insertAdjacentHTML`/`document.write`/`document.writeln` — все три ошибки найдены;
- `innerHTML = html as string` — ошибка найдена;
- безопасный `textContent` и обычная TSX-интерполяция — без ошибок.
Отдельный запуск реальных правил `pii/no-email` и
`xss/no-location-href-assign` завершился
`TypeError: context.getSourceCode is not a function` на ESLint 10.
Это подтверждает несовместимость проверенных версий, несмотря на широкий
или отсутствующий peer range. Обходы через legacy adapters не предлагаются.
Поиск секретов уже выполняют [security workflow](.gitea/workflows/security.yml)
и pre-commit Gitleaks; SonarJS дополнительно проверяет hardcoded secrets.
Добавление ещё одного secret-scanner в ESLint сейчас не оправдано.
Активацию обоих правил и примеры опасных DOM-вставок проверяет
`npm run test:lint`; интеграцию зависимости — полный `npm run verify`.
-78
View File
@@ -1,78 +0,0 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"useIgnoreFile": true
},
"files": {
"includes": [
"**",
"!dist",
"!storybook-static",
"!src/shared/api/generated",
"!src/shared/api/spec",
"!coverage",
"!node_modules"
]
},
"linter": {
"rules": {
"preset": "recommended",
"nursery": {
"useReactFunctionComponentDefinition": {
"level": "error",
"options": {
"namedComponents": "arrowFunction"
}
}
},
"style": {
"noDefaultExport": "error",
"useExportsLast": "error",
"noMagicNumbers": "error"
}
}
},
"overrides": [
{
"includes": [
"vite.config.ts",
"vitest.config.ts",
".storybook/main.ts",
".storybook/preview.tsx"
],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
},
{
"includes": ["**/*.stories.tsx"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off",
"useExportsLast": "off"
}
}
}
},
{
"includes": [
"**/*.test.ts",
"**/*.test.tsx",
"**/*.stories.tsx",
"src/test/**"
],
"linter": {
"rules": {
"style": {
"noMagicNumbers": "off"
}
}
}
}
]
}
+32 -9
View File
@@ -1,50 +1,73 @@
module.exports = {
forbidden: [
{
from: {},
name: "no-unresolved",
severity: "error",
to: { couldNotResolve: true },
},
{
from: { path: "^packages/" },
name: "packages-must-not-depend-on-app",
severity: "error",
to: { path: "^src/" },
},
{
from: {},
name: "no-circular",
severity: "error",
from: {},
to: { circular: true },
},
{
name: "slices-must-use-public-api",
severity: "error",
from: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/",
},
name: "slices-must-use-public-api",
severity: "error",
to: {
path: "^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\\.ts$)",
path: String.raw`^src/(app|pages|features|entities|shared)/([^/]+)/(?!index\.ts$)`,
pathNot: "^src/$1/$2/",
},
},
{
from: { path: "^src/(pages|features|entities)/([^/]+)/" },
name: "no-same-layer-cross-slice-imports",
severity: "error",
to: { path: "^src/$1/", pathNot: "^src/$1/$2/" },
},
{
from: { path: "^src/shared/" },
name: "shared-must-not-depend-on-layers",
severity: "error",
from: { path: "^src/shared/" },
to: { path: "^src/(entities|features|pages|app)/" },
},
{
from: { path: "^src/entities/" },
name: "entities-must-not-depend-on-higher-layers",
severity: "error",
from: { path: "^src/entities/" },
to: { path: "^src/(features|pages|app)/" },
},
{
from: { path: "^src/features/" },
name: "features-must-not-depend-on-higher-layers",
severity: "error",
from: { path: "^src/features/" },
to: { path: "^src/(pages|app)/" },
},
{
from: { path: "^src/pages/" },
name: "pages-must-not-depend-on-app",
severity: "error",
from: { path: "^src/pages/" },
to: { path: "^src/app/" },
},
],
options: {
doNotFollow: { path: "node_modules" },
tsConfig: { fileName: "tsconfig.app.json" },
enhancedResolveOptions: {
conditionNames: ["import", "require", "node", "default"],
exportsFields: ["exports"],
},
exclude: "^packages/[^/]+/dist/",
tsConfig: { fileName: "tsconfig.dependency-cruiser.json" },
tsPreCompilationDeps: true,
},
};
+162
View File
@@ -0,0 +1,162 @@
import react from "@eslint-react/eslint-plugin";
import js from "@eslint/js";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import noUnsanitized from "eslint-plugin-no-unsanitized";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals";
import tseslint from "typescript-eslint";
export default defineConfig(
globalIgnores([
"**/dist/**", "dist-ssr/**", "storybook-static/**", "coverage/**",
".vitest/**", ".scannerwork/**", "src/shared/api/generated/**", "src/shared/api/spec/**",
]),
{
extends: [
js.configs.recommended,
importX.flatConfigs.recommended,
unicorn.configs.recommended,
sonarjs.configs.recommended,
regexp.configs.recommended,
security.configs.recommended,
noUnsanitized.configs.recommended,
perfectionist.configs["recommended-natural"],
stylistic.configs.customize({ indent: "tab", jsx: true, quotes: "double", semi: true }),
],
files: ["**/*.{js,mjs,cjs,ts,tsx}"],
linterOptions: { reportUnusedDisableDirectives: "error" },
rules: {
// A comma disambiguates generic arrow functions from JSX in TSX.
"@stylistic/comma-dangle": ["error", {
arrays: "always-multiline", enums: "always-multiline", exports: "always-multiline",
functions: "always-multiline", generics: "ignore", imports: "always-multiline",
objects: "always-multiline", tuples: "always-multiline",
}],
// Keep text/interpolation together: splitting can change rendered whitespace.
"@stylistic/jsx-one-expression-per-line": ["error", { allow: "non-jsx" }],
"@stylistic/multiline-ternary": ["error", "always-multiline"],
"@stylistic/no-tabs": ["error", { allowIndentationTabs: true }],
"@stylistic/operator-linebreak": ["error", "before"],
"perfectionist/sort-classes": ["error", { groups: ["static-method", "property", "method", "unknown"], type: "natural" }],
// Preserve the repository's component, hook and config file naming conventions.
"unicorn/filename-case": ["error", { cases: { camelCase: true, kebabCase: true, pascalCase: true } }],
// React uses Props/Ref names, including the recommended React ref-name rule.
"unicorn/name-replacements": ["error", { replacements: { props: false, ref: false } }],
// React, Mantine and Fetch accept null at their documented call boundaries.
"unicorn/no-null": ["error", { checkArguments: false }],
},
settings: {
"import-x/resolver-next": [createTypeScriptImportResolver({ project: "tsconfig.json" })],
},
},
{
files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"],
languageOptions: { globals: globals.node },
},
{
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
files: ["**/*.{ts,tsx}"],
languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } },
rules: {
// React ignores event handler return values; floating promises are still checked in bodies.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
"@typescript-eslint/switch-exhaustiveness-check": "error",
},
},
{
extends: [react.configs["recommended-type-checked"], hooks.configs.flat.recommended, query.configs["flat/recommended"]],
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}", ".storybook/preview.tsx"],
languageOptions: { globals: globals.browser },
rules: {
"import-x/exports-last": "error",
"import-x/no-default-export": "error",
},
},
{
extends: [refresh.configs.vite],
files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"],
ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"],
},
{
files: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"],
ignores: ["**/*.test.{ts,tsx}", "**/*.stories.tsx", "src/test/**"],
rules: {
"@typescript-eslint/no-magic-numbers": ["error", {
ignore: [-1, 0, 1, "-1n", "0n", "1n"], ignoreArrayIndexes: true, ignoreDefaultValues: true,
ignoreNumericLiteralTypes: true, ignoreTypeIndexes: true,
}],
},
},
{
extends: [vitest.configs.recommended],
files: ["**/*.test.{ts,tsx}"],
rules: {
"vitest/no-focused-tests": "error",
"vitest/valid-expect": ["error", { maxArgs: 2 }],
},
},
{
extends: [testingLibrary.configs["flat/react"], jestDom.configs["flat/recommended"]],
files: ["**/*.test.tsx"],
},
storybook.configs["flat/recommended"],
{
files: ["**/*.stories.tsx", ".storybook/preview.tsx"],
rules: { "import-x/exports-last": "off", "import-x/no-default-export": "off" },
},
{
files: [
"src/**/*.{ts,tsx}",
"packages/*/src/**/*.{ts,tsx}",
],
ignores: [
"**/*.test.{ts,tsx}",
"**/*.stories.tsx",
"src/test/**",
],
rules: {
"@typescript-eslint/no-unsafe-enum-comparison": "error",
"no-restricted-syntax": [
"error",
{
message: "Use the owning enum member for machine codes and protocol values.",
selector: "Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)",
},
{
message: "Use the owning enum member instead of a template machine code.",
selector: "TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)",
},
{
message: "Use an enum member instead of an inline code or protocol value.",
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]",
},
{
message: "Use an enum member instead of constructing a code or protocol value.",
selector: "Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value",
},
{
message: "Use a computed enum member as the protocol mapping key.",
selector: "Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]",
},
{
message: "Compare codes with their enum members, not string literals.",
selector: "BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]",
},
],
},
},
);
+3 -1
View File
@@ -1,8 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WealthLedger</title>
</head>
+15 -2
View File
@@ -1,4 +1,17 @@
{
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"],
"ignoreDependencies": ["conventional-changelog-conventionalcommits"]
"ignoreDependencies": ["conventional-changelog-conventionalcommits"],
"workspaces": {
".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
},
"packages/tokens": {
"entry": ["tokens.json"]
},
"packages/ui": {
"project": ["src/**/*.{ts,tsx}"]
},
"packages/icons": {
"project": ["src/**/*.{ts,tsx}"]
}
}
}
+7667 -176
View File
File diff suppressed because it is too large Load Diff
+51 -18
View File
@@ -1,36 +1,40 @@
{
"name": "wealth-ledger-frontend",
"private": true,
"version": "0.2.3",
"version": "0.4.0",
"type": "module",
"engines": {
"node": "24.21.0"
},
"scripts": {
"dev": "vite",
"dev:catalog": "storybook dev -p 6006 --no-open --disable-telemetry",
"build:catalog": "storybook build --output-dir storybook-static --disable-telemetry",
"dev": "npm run build:packages && vite",
"dev:catalog": "npm run build:packages && storybook dev -p 6006 --no-open --disable-telemetry",
"build:catalog": "npm run build:packages && storybook build --output-dir storybook-static --disable-telemetry",
"preview:catalog": "vite preview --outDir storybook-static",
"typecheck": "tsc -b --noEmit",
"build": "tsc -b && vite build",
"lint": "biome check .",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "npm run build:packages && tsc -b --noEmit",
"build": "npm run build:packages && tsc -b && vite build",
"lint": "npm run lint:eslint",
"lint:eslint": "npm run build:packages && eslint . --max-warnings=0",
"test": "npm run build:packages && vitest",
"test:run": "npm run build:packages && vitest run",
"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",
"knip": "knip",
"deps": "depcruise src --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog",
"knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && 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",
"prepare": "husky",
"preview": "vite preview",
"deps:storybook": "depcruise src .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
"deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long",
"release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci",
"commitlint": "commitlint"
"commitlint": "commitlint",
"build:packages": "npm run build --workspaces --if-present",
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
},
"lint-staged": {
"*.{css,html,json,md,ts,tsx}": "biome format --write --no-errors-on-unmatched"
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
},
"dependencies": {
"@hookform/resolvers": "5.9.1",
@@ -39,6 +43,9 @@
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
"@tanstack/react-query": "5.102.8",
"@wealthledger/icons": "file:packages/icons",
"@wealthledger/tokens": "file:packages/tokens",
"@wealthledger/ui": "file:packages/ui",
"dayjs": "1.11.23",
"i18next": "26.4.2",
"motion": "13.2.0",
@@ -55,37 +62,63 @@
},
"devDependencies": {
"@babel/core": "8.0.5",
"@biomejs/biome": "2.5.13",
"@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2",
"@eslint-react/eslint-plugin": "5.21.0",
"@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.0",
"@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.0",
"@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.0",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@types/node": "24.13.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "6.1.1",
"@vitest/browser-playwright": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/eslint-plugin": "1.6.27",
"babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.0",
"dependency-cruiser": "18.2.0",
"eslint": "10.11.0",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest-dom": "5.10.1",
"eslint-plugin-no-unsanitized": "4.1.5",
"eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.1.0",
"eslint-plugin-sonarjs": "4.2.1",
"eslint-plugin-storybook": "10.6.0",
"eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0",
"globals": "17.12.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"knip": "6.35.1",
"lint-staged": "17.5.1",
"msw": "2.15.0",
"openapi-typescript": "7.13.0",
"playwright": "1.63.0",
"release-it": "21.0.2",
"storybook": "10.6.0",
"typescript": "5.9.3",
"typescript-eslint": "8.70.1",
"vite": "8.3.0",
"vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0"
},
"allowScripts": {
"esbuild@0.28.2": true,
"fsevents@2.3.3": true
}
},
"workspaces": [
"packages/*"
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@wealthledger/icons",
"version": "0.3.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"sideEffects": false,
"scripts": {
"build": "vite build && tsc -p tsconfig.json"
},
"peerDependencies": {
"react": "19.3.0"
},
"devDependencies": {
"@storybook/react-vite": "10.6.0",
"@testing-library/react": "16.3.3",
"@types/react": "19.3.0",
"typescript": "5.9.3",
"vite": "8.3.0",
"vitest": "5.0.0"
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AddIcon, DeleteIcon, EditIcon } from "./index";
const meta = {
component: AddIcon,
parameters: {
docs: {
description: {
component:
"Статичные SVG: size по умолчанию 24, strokeWidth 1.5, цвет currentColor. В кнопке иконка декоративная, имя принадлежит действию. Для самостоятельного изображения задайте aria-hidden={false}, role=img и aria-label либо aria-labelledby. Геометрия взята из Card/Cards; подписи и действия остаются у приложения.",
},
},
},
tags: ["autodocs"],
title: "Design System/Icons",
} satisfies Meta<typeof AddIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Decorative: Story = {
render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<button aria-label="Добавить пример" type="button">
<AddIcon size={20} strokeWidth={2} />
</button>
<button aria-label="Изменить пример" type="button">
<EditIcon size={18} />
</button>
<button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} />
</button>
</div>
),
};
export const Meaningful: Story = {
render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div>
),
};
+36
View File
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AddIcon, DeleteIcon, EditIcon } from "./index";
describe("icon accessibility", () => {
it.each([AddIcon, EditIcon, DeleteIcon])(
"keeps decoration hidden and exposes explicitly named images: %s",
(Icon) => {
const { rerender } = render(
<button aria-label="Действие" type="button">
<Icon />
</button>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
).toHaveAttribute("focusable", "false");
rerender(
<Icon
aria-hidden={false}
aria-label="Пример"
role="img"
size={18}
strokeWidth={2}
/>,
);
const icon = screen.getByRole("img", { name: "Пример" });
expect(icon).toHaveAttribute("width", "18");
expect(icon).toHaveAttribute("height", "18");
expect(icon).toHaveAttribute("stroke-width", "2");
expect(icon).toHaveAttribute("stroke", "currentColor");
},
);
});
+4
View File
@@ -0,0 +1,4 @@
export { AddIcon } from "./svg/AddIcon";
export { DeleteIcon } from "./svg/DeleteIcon";
export { EditIcon } from "./svg/EditIcon";
export type { IconProps } from "./types";
+25
View File
@@ -0,0 +1,25 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const AddIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M12 5v14 M5 12h14" />
</svg>
);
};
export { AddIcon };
+26
View File
@@ -0,0 +1,26 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const DeleteIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
</svg>
);
};
export { DeleteIcon };
+26
View File
@@ -0,0 +1,26 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const EditIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
</svg>
);
};
export { EditIcon };
+10
View File
@@ -0,0 +1,10 @@
import type { ComponentPropsWithoutRef } from "react";
type IconProps = Omit<
ComponentPropsWithoutRef<"svg">,
"children" | "dangerouslySetInnerHTML"
> & {
size?: number | string;
};
export type { IconProps };
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true,
"emitDeclarationOnly": true,
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx"
},
"include": ["src/index.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: { entry: "src/index.ts", fileName: "index", formats: ["es"] },
rolldownOptions: { external: /^react(\/|$)/ },
},
});
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@wealthledger/tokens",
"version": "0.3.0",
"private": true,
"type": "module",
"files": [
"tokens.json"
],
"exports": {
"./tokens.json": "./tokens.json"
}
}
@@ -53,7 +53,7 @@
"#40c057",
"#37b24d",
"#2f9e44",
"#2b8a3e"
"#237032"
],
"red": [
"#fff5f5",
@@ -131,7 +131,7 @@
},
"muted": {
"color": "gray",
"shade": 6,
"shade": 7,
"description": "Вторичный текст; проверяйте контраст при выборе фона"
},
"border": {
@@ -151,12 +151,12 @@
},
"positive": {
"color": "green",
"shade": 6,
"shade": 9,
"description": "Положительный результат; дополните знаком и текстом"
},
"negative": {
"color": "red",
"shade": 6,
"shade": 9,
"description": "Ошибка / отрицательный результат; дополните текстом"
},
"warning": {
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@wealthledger/ui",
"version": "0.3.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./theme": {
"types": "./dist/theme.d.ts",
"import": "./dist/theme.js"
},
"./styles.css": "./dist/styles.css"
},
"sideEffects": [
"**/*.css"
],
"scripts": {
"build": "vite build && tsc -p tsconfig.json"
},
"dependencies": {
"@wealthledger/tokens": "0.3.0"
},
"peerDependencies": {
"@mantine/core": "9.6.1",
"@mantine/hooks": "9.6.1",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@storybook/react-vite": "10.6.0",
"@testing-library/react": "16.3.3",
"@types/react": "19.3.0",
"storybook": "10.6.0",
"typescript": "5.9.3",
"vite": "8.3.0",
"vitest": "5.0.0"
}
}
@@ -4,8 +4,8 @@
gap: var(--mantine-spacing-sm);
align-items: center;
padding: var(--mantine-spacing-md);
border-color: var(--mantine-color-gray-3);
background: var(--mantine-color-gray-0);
border-color: var(--wl-color-border);
background: var(--wl-color-subtle);
}
.identity,
@@ -19,15 +19,15 @@
}
.positive {
color: var(--mantine-color-green-6);
color: var(--wl-color-positive);
}
.negative {
color: var(--mantine-color-red-6);
color: var(--wl-color-negative);
}
.neutral {
color: var(--mantine-color-dimmed);
color: var(--mantine-color-gray-7);
}
@media (max-width: 36em) {
@@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@mantine/core";
import { fn } from "storybook/test";
import { EntryCard } from "./EntryCard";
const meta = {
args: {
actions: (
<Button onClick={fn()} size="compact-sm">
Открыть пример
</Button>
),
imageSrc: "data:image/png;base64,invalid",
initial: "У",
metadata: "22.09.2026 · Синтетические данные",
title:
"Учебная запись с очень длинным названием для проверки узкого экрана",
tone: "negative",
value: "−1 250,00 ₽",
valueLabel: "Тестовое значение",
},
component: EntryCard,
parameters: {
docs: {
description: {
component:
"Готовые строки и slots без финансовых вычислений. Невалидное локальное изображение показывает fallback; действие доступно с клавиатуры и на узком экране. Данные вымышлены.",
},
},
},
tags: ["autodocs"],
title: "Design System/Components/EntryCard",
} satisfies Meta<typeof EntryCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LongContentAndFallback: Story = {};
@@ -0,0 +1,62 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { assert, describe, expect, it } from "vitest";
import { EntryCard } from "./EntryCard";
describe("EntryCard", () => {
it("does not leak a numeric zero from an absent action", () => {
render(
<MantineProvider>
<EntryCard
actions={0}
initial="У"
metadata="Описание"
title="Пример"
tone="neutral"
value="—"
valueLabel="Неизвестно"
/>
</MantineProvider>,
);
expect(screen.queryByText("0")).not.toBeInTheDocument();
});
it("keeps a failed image fallback until a new source is supplied", () => {
const props = {
imageSrc: "data:image/png;base64,invalid",
initial: "У",
metadata: "Описание",
title: "Пример",
tone: "neutral" as const,
value: "—",
valueLabel: "Неизвестно",
};
const { rerender } = render(
<MantineProvider>
<EntryCard {...props} />
</MantineProvider>,
);
// Decorative image has no accessible name; trigger its actual failure event.
const image = screen.queryByAltText("");
assert(image);
fireEvent.error(image);
rerender(
<MantineProvider>
<EntryCard {...props} title="Обновлённый пример" />
</MantineProvider>,
);
expect(screen.queryByAltText("")).not.toBeInTheDocument();
expect(screen.getByText("У")).toBeInTheDocument();
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
rerender(
<MantineProvider>
<EntryCard {...props} imageSrc="/replacement-logo.png" />
</MantineProvider>,
);
expect(screen.queryByAltText("")).toHaveAttribute(
"src",
"/replacement-logo.png",
);
expect(screen.queryByText("У")).not.toBeInTheDocument();
});
});
@@ -1,42 +1,42 @@
import type { FC, ReactNode } from "react";
import { Avatar, Paper, Stack, Text } from "@mantine/core";
import { type FC, type ReactNode, useState } from "react";
import classes from "./EntryCard.module.css";
type EntryCardProps = {
title: string;
metadata: string;
details?: ReactNode;
actions?: ReactNode;
initial: string;
icon?: ReactNode;
color?: string;
imageSrc?: string | null;
details?: ReactNode;
icon?: ReactNode;
imageSrc?: null | string;
initial: string;
metadata: string;
title: string;
tone: "negative" | "neutral" | "positive";
value: string;
valueLabel: string;
tone: "positive" | "negative" | "neutral";
};
const EntryCard: FC<EntryCardProps> = (props) => {
const [imageFailed, setImageFailed] = useState(false);
return (
<Paper
className={classes.row}
withBorder
style={
props.color
? {
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, white)`,
}
(props.color)
? ({
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
})
: undefined
}
withBorder
>
<Avatar
alt=""
aria-hidden="true"
radius="md"
size={40}
src={imageFailed ? null : props.imageSrc}
onError={() => setImageFailed(true)}
src={props.imageSrc}
>
{props.icon ?? props.initial}
</Avatar>
@@ -53,14 +53,14 @@ const EntryCard: FC<EntryCardProps> = (props) => {
<Text className={classes[props.tone]} fw={700}>
{props.value}
</Text>
<div className={props.actions ? classes.valueFooter : undefined}>
<div className={(props.actions) ? (classes.valueFooter) : undefined}>
<Text
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
size="sm"
>
{props.valueLabel}
</Text>
{props.actions && (
{Boolean(props.actions) && (
<div className={classes.actions}>{props.actions}</div>
)}
</div>
@@ -1,16 +1,16 @@
import { Paper, Skeleton } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Paper, Skeleton } from "@mantine/core";
import { LoadingSkeleton } from "./LoadingSkeleton";
const meta = {
title: "Design System/Components/LoadingSkeleton",
component: LoadingSkeleton,
tags: ["autodocs"],
args: { label: "Загрузка содержимого" },
argTypes: {
label: { control: "text" },
children: { table: { disable: true } },
label: { control: "text" },
},
component: LoadingSkeleton,
parameters: {
docs: {
description: {
@@ -19,15 +19,17 @@ const meta = {
},
},
},
render: (args) => (
render: arguments_ => (
<Paper p="md" withBorder>
<LoadingSkeleton {...args}>
<LoadingSkeleton {...arguments_}>
<Skeleton h={24} w="60%" />
<Skeleton h={72} />
<Skeleton h={16} w="80%" />
</LoadingSkeleton>
</Paper>
),
tags: ["autodocs"],
title: "Design System/Components/LoadingSkeleton",
} satisfies Meta<typeof LoadingSkeleton>;
export default meta;
@@ -1,6 +1,7 @@
import { Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import { Stack } from "@mantine/core";
type LoadingSkeletonProps = PropsWithChildren<{
label: string;
}>;
@@ -1,36 +1,36 @@
import { Button } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ComponentProps } from "react";
import { Button } from "@mantine/core";
import { fn } from "storybook/test";
import { StateView } from "./StateView";
type StateStoryArgs = ComponentProps<typeof StateView> & {
withAction: boolean;
type StateStoryArguments = ComponentProps<typeof StateView> & {
onAction: () => void;
withAction: boolean;
};
const meta = {
title: "Design System/Components/StateView",
component: StateView,
tags: ["autodocs"],
args: {
title: "Нет учебных данных",
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
error: false,
withAction: false,
onAction: fn(),
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
title: "Нет учебных данных",
withAction: false,
},
argTypes: {
title: { control: "text" },
text: { control: "text" },
error: { control: "boolean" },
action: { table: { disable: true } },
error: { control: "boolean" },
onAction: { control: false },
text: { control: "text" },
title: { control: "text" },
withAction: {
control: "boolean",
description: "Показать необязательное действие StateView",
},
onAction: { control: false },
},
component: StateView,
parameters: {
docs: {
description: {
@@ -39,40 +39,44 @@ const meta = {
},
},
},
render: (args) => {
const { withAction, onAction, ...stateProps } = args;
render: (arguments_) => {
const { onAction, withAction, ...stateProps } = arguments_;
return (
<StateView
{...stateProps}
action={
withAction ? (
<Button onClick={onAction} variant="default">
{args.error ? "Повторить локально" : "Выполнить пример"}
</Button>
) : undefined
(withAction)
? ((
<Button onClick={onAction} variant="default">
{arguments_.error ? "Повторить локально" : "Выполнить пример"}
</Button>
))
: undefined
}
/>
);
},
} satisfies Meta<StateStoryArgs>;
tags: ["autodocs"],
title: "Design System/Components/StateView",
} satisfies Meta<StateStoryArguments>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Empty: Story = { name: "Пустое состояние" };
export const EmptyWithAction: Story = {
name: "Пустое состояние с действием",
args: { withAction: true },
name: "Пустое состояние с действием",
};
export const Failed: Story = {
name: "Ошибка",
args: {
error: true,
title: "Учебная ошибка",
text: "Демонстрация недоступных данных без вызова API.",
title: "Учебная ошибка",
},
name: "Ошибка",
};
export const ErrorWithAction: Story = {
name: "Ошибка с retry",
args: { ...Failed.args, withAction: true },
name: "Ошибка с retry",
};
@@ -1,7 +1,8 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { LoadingSkeleton } from "./LoadingSkeleton";
import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton";
import { StateView } from "./StateView";
describe("StateView", () => {
@@ -1,19 +1,20 @@
import { Paper, Stack, Text, Title } from "@mantine/core";
import type { FC, ReactNode } from "react";
import { Paper, Stack, Text, Title } from "@mantine/core";
type StateViewProps = {
title: string;
text: string;
action?: ReactNode;
error?: boolean;
text: string;
title: string;
};
const StateView: FC<StateViewProps> = (props) => {
const { title, text, action, error = false } = props;
const { action, error = false, text, title } = props;
const role = error ? "alert" : undefined;
return (
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
<Paper p="xl" radius="md" role={role} shadow="xs" withBorder>
<Stack align="flex-start" gap="sm">
<Title order={2}>{title}</Title>
<Text c="dimmed">{text}</Text>
+3
View File
@@ -0,0 +1,3 @@
export { EntryCard } from "./EntryCard/EntryCard";
export { LoadingSkeleton } from "./LoadingSkeleton/LoadingSkeleton";
export { StateView } from "./StateView/StateView";
@@ -1,7 +1,8 @@
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
import source from "@wealthledger/tokens/tokens.json";
import { describe, expect, it } from "vitest";
import { colorRoles, cssVariablesResolver, theme } from "./theme";
import source from "./tokens.json";
describe("design theme adapter", () => {
it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => {
@@ -25,5 +26,8 @@ describe("design theme adapter", () => {
expect(Object.keys(variables)).toHaveLength(
Object.keys(source.color.roles).length,
);
expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe(
variables["--wl-color-muted"],
);
});
});
@@ -1,85 +1,90 @@
import "./styles.css";
import {
type CSSVariablesResolver,
colorsTuple,
createTheme,
type CSSVariablesResolver,
getThemeColor,
type MantinePrimaryShade,
Modal,
rem,
rgba,
} from "@mantine/core";
import designTokens from "./tokens.json";
import designTokens from "@wealthledger/tokens/tokens.json";
const { color, typography, spacing, radius, shadows, breakpoints } =
designTokens;
const { breakpoints, color, radius, shadows, spacing, typography }
= designTokens;
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
Object.fromEntries(
Object.entries(values).map(([name, value]) => [name, convert(value)]),
);
const colorRoles = mapValues(color.roles, (role) =>
const colorRoles = mapValues(color.roles, role =>
role.shade === null ? role.color : `${role.color}.${role.shade}`,
);
const theme = createTheme({
respectReducedMotion: true,
black: color.base.black,
breakpoints: mapValues(
breakpoints.values,
value => `${value}${breakpoints.unit}`,
),
colors: mapValues(color.palettes, colorsTuple),
components: {
Modal: Modal.extend({
defaultProps: {
overlayProps: { blur: 3 },
transitionProps: {
transition: "fade-down",
duration: 220,
exitDuration: 180,
timingFunction: "ease-out",
transition: "fade-down",
},
},
}),
},
primaryColor: color.roles.primary.color,
primaryShade: color.primaryShade as MantinePrimaryShade,
defaultRadius: radius.default,
white: color.base.white,
black: color.base.black,
colors: mapValues(color.palettes, colorsTuple),
fontFamily: typography.fontFamily,
fontFamilyMonospace: typography.fontFamilyMonospace,
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
fontSizes: mapValues(typography.fontSizes, value => rem(value)),
fontWeights: mapValues(typography.fontWeights, String),
lineHeights: mapValues(typography.lineHeights, String),
headings: {
fontFamily: typography.fontFamily,
fontWeight: String(typography.fontWeights.bold),
sizes: mapValues(typography.headings, (value) => ({
sizes: mapValues(typography.headings, value => ({
fontSize: rem(value.fontSize),
lineHeight: String(value.lineHeight),
})),
},
spacing: mapValues(spacing.values, (value) => rem(value)),
radius: mapValues(radius.values, (value) => rem(value)),
shadows: mapValues(shadows.values, (layers) =>
lineHeights: mapValues(typography.lineHeights, String),
primaryColor: color.roles.primary.color,
primaryShade: color.primaryShade as MantinePrimaryShade,
radius: mapValues(radius.values, value => rem(value)),
respectReducedMotion: true,
shadows: mapValues(shadows.values, layers =>
layers
.map(
(layer) =>
layer =>
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
)
.join(", "),
),
breakpoints: mapValues(
breakpoints.values,
(value) => `${value}${breakpoints.unit}`,
),
spacing: mapValues(spacing.values, value => rem(value)),
white: color.base.white,
});
const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
const cssVariablesResolver: CSSVariablesResolver = resolvedTheme => ({
dark: {},
light: {
"--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme),
},
variables: Object.fromEntries(
Object.entries(colorRoles).map(([role, reference]) => [
`--wl-color-${role}`,
getThemeColor(reference, resolvedTheme),
]),
),
light: {},
dark: {},
});
export { colorRoles, cssVariablesResolver, designTokens, theme };
export { colorRoles, cssVariablesResolver, theme };
export { default as designTokens } from "@wealthledger/tokens/tokens.json";
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"declaration": true,
"emitDeclarationOnly": true,
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src/index.ts", "src/theme.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
cssFileName: "styles",
entry: { index: "src/index.ts", theme: "src/theme.ts" },
formats: ["es"],
},
rolldownOptions: {
external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
},
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+79
View File
@@ -0,0 +1,79 @@
const assert = require("node:assert/strict");
const {
chmodSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} = require("node:fs");
const { tmpdir } = require("node:os");
const nodePath = require("node:path");
const { test } = require("node:test");
const EXECUTABLE_MODE = 0o755;
const { spawnSync } = require("node:child_process");
test("immutable publication resumes exact digest checks after post-push failure", () => {
const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-"));
const workflow = readFileSync(
nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"),
"utf8",
);
const block = workflow
.split(" - name: Publish immutable release tag\n run: |\n", 2)[1]
.split(" - name:", 1)[0]
.replaceAll(/^ {10}/gm, "");
const double = String.raw`#!${process.execPath}
const fs = require('node:fs');
const args = process.argv.slice(2).join(' ');
fs.appendFileSync('calls', args + '\n');
if (args.startsWith('manifest inspect')) {
if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); }
} else if (args.startsWith('buildx imagetools inspect')) {
console.log('sha256:exact');
} else if (args.startsWith('buildx build')) {
fs.writeFileSync('published', 'yes');
fs.writeFileSync('image-metadata.json', JSON.stringify({'containerimage.digest': 'sha256:exact'}));
} else if (args.startsWith('pull')) {
if (!fs.existsSync('pull-failed')) { fs.writeFileSync('pull-failed', 'yes'); process.exit(1); }
} else if (args.startsWith('image inspect')) {
console.log(args.includes('image.version') ? '1.2.3' : process.env.BAD_REVISION ? 'other' : 'commit');
} else if (args.startsWith('run') && process.env.BAD_SMOKE) process.exit(1);
`;
try {
for (const name of ["docker", "npm"]) {
// 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(directory, name), double);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE);
}
const run = (extra = {}) =>
spawnSync("/bin/sh", ["-ec", block], {
cwd: directory,
encoding: "utf8",
env: {
...process.env,
IMAGE: "synthetic/image",
PATH: `${directory}:${process.env.PATH}`,
RELEASE_TAG: "v1.2.3",
REVISION: "commit",
...extra,
},
});
assert.notEqual(run().status, 0);
const retry = run();
assert.equal(retry.status, 0, retry.stdout + retry.stderr);
assert.notEqual(run({ BAD_REVISION: "1" }).status, 0);
assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
const calls = readFileSync(nodePath.join(directory, "calls"), "utf8");
assert.equal(
calls.split("\n").filter(line => line.startsWith("buildx build"))
.length,
1,
);
assert.ok(calls.includes("synthetic/image@sha256:exact"));
}
finally {
rmSync(directory, { force: true, recursive: true });
}
});
+14 -11
View File
@@ -1,14 +1,14 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import nodePath from "node:path";
const temporaryDirectory = await mkdtemp(
join(tmpdir(), "wealth-ledger-openapi-"),
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
);
const generatedPath = join(temporaryDirectory, "schema.d.ts");
const committedPath = resolve("src/shared/api/generated/schema.d.ts");
const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js");
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 {
@@ -18,12 +18,10 @@ try {
{ encoding: "utf8" },
);
if (generated.status !== 0) {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
} else {
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) {
@@ -33,8 +31,13 @@ try {
exitCode = 1;
}
}
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
else {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
}
}
finally {
await rm(temporaryDirectory, { force: true, recursive: true });
}
process.exitCode = exitCode;
+6 -4
View File
@@ -1,14 +1,16 @@
const assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process");
const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim();
assert.equal(
git("status", "--porcelain"),
"",
"Commit all changes before preparing a release",
);
const tags = git(
const tag_ = git(
"tag",
"--merged",
"HEAD",
@@ -17,9 +19,9 @@ const tags = git(
"--sort=-version:refname",
)
.split("\n")
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
.find(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(
version,
tags[0]?.slice(1) ?? "0.0.0",
tag_?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.",
);
+211
View File
@@ -0,0 +1,211 @@
import react from "@eslint-react/eslint-plugin";
import stylistic from "@stylistic/eslint-plugin";
import query from "@tanstack/eslint-plugin-query";
import vitest from "@vitest/eslint-plugin";
import { ESLint } from "eslint";
import { importX } from "eslint-plugin-import-x";
import jestDom from "eslint-plugin-jest-dom";
import noUnsanitized from "eslint-plugin-no-unsanitized";
import perfectionist from "eslint-plugin-perfectionist";
import hooks from "eslint-plugin-react-hooks";
import refresh from "eslint-plugin-react-refresh";
import regexp from "eslint-plugin-regexp";
import security from "eslint-plugin-security";
import sonarjs from "eslint-plugin-sonarjs";
import storybook from "eslint-plugin-storybook";
import testingLibrary from "eslint-plugin-testing-library";
import unicorn from "eslint-plugin-unicorn";
import assert from "node:assert/strict";
import { test } from "node:test";
import tseslint from "typescript-eslint";
test("ESLint enforces the frontend profile and excludes generated artifacts", async () => {
const eslint = new ESLint();
for (const [filePath, code, rule] of [
[
"src/shared/api/problem-details.ts",
"export function run() { Promise.resolve(); }",
"@typescript-eslint/no-floating-promises",
],
[
"packages/ui/src/EntryCard/EntryCard.tsx",
"import { useState } from \"react\"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }",
"react-hooks/rules-of-hooks",
],
[
"src/shared/api/problem-details.ts",
"export const values = Array(3).fill({});",
"unicorn/no-array-fill-with-reference-type",
],
[
"src/shared/api/problem-details.ts",
"export default 1;",
"import-x/no-default-export",
],
[
"src/shared/api/problem-details.ts",
"new Error(\"forgot to throw\");",
"sonarjs/no-unthrown-error",
],
[
"src/shared/api/problem-details.ts",
"export const pattern = /[aab]/;",
"regexp/no-dupe-characters-character-class",
],
[
"src/shared/api/problem-details.ts",
"export type Status = \"z\" | \"a\";",
"perfectionist/sort-union-types",
],
[
"src/shared/api/problem-details.ts",
"//missing space\nexport const ready = true;",
"@stylistic/spaced-comment",
],
[
"src/shared/api/problem-details.ts",
"export function pattern(input: string) { return new RegExp(input); }",
"security/detect-non-literal-regexp",
],
[
"scripts/check-openapi.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process",
],
[
"src/shared/api/problem-details.ts",
"export function renderHtml(element: HTMLElement, html: string) { element.innerHTML = html; }",
"no-unsanitized/property",
],
[
"src/shared/api/problem-details.ts",
"export function renderHtml(element: HTMLElement, html: string) { element.insertAdjacentHTML(\"beforeend\", html); }",
"no-unsanitized/method",
],
[
"packages/ui/src/EntryCard/EntryCard.test.tsx",
"import { expect } from \"vitest\"; expect(document.activeElement).toBe(document.body);",
"jest-dom/prefer-focus",
],
[
"src/shared/api/client.test.ts",
"import { it } from \"vitest\"; it.only(\"focused\", () => {});",
"vitest/no-focused-tests",
],
[
"packages/ui/src/EntryCard/EntryCard.test.tsx",
"import { screen } from \"@testing-library/react\"; screen.findByText(\"ready\");",
"testing-library/await-async-queries",
],
[
"packages/ui/src/EntryCard/EntryCard.tsx",
"import { QueryClient } from \"@tanstack/react-query\"; export function Example() { const client = new QueryClient(); return <div>{String(client)}</div>; }",
"@tanstack/query/stable-query-client",
],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, filePath);
assert.ok(
result.messages.some(message => message.ruleId === rule),
rule,
);
}
for (const file of [
"src/shared/api/generated/schema.d.ts",
"packages/ui/dist/index.js",
"storybook-static/index.js",
"coverage/index.js",
])
assert.equal(await eslint.isPathIgnored(file), true, file);
const story = await eslint.calculateConfigForFile(
"src/shared/ui/Button.stories.tsx",
);
assert.equal(story.rules["import-x/no-default-export"][0], 0);
assert.ok(story.rules["storybook/default-exports"][0] > 0);
assert.equal(story.rules["react-refresh/only-export-components"], undefined);
});
test("all recommended plugin rules remain enabled in their intended scopes", async () => {
const eslint = new ESLint();
const source = "src/shared/api/problem-details.ts";
const component = "packages/ui/src/EntryCard/EntryCard.tsx";
const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx";
for (const [preset, filePath] of [
[tseslint.configs.recommendedTypeChecked, source],
[[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source],
[unicorn.configs.recommended, source],
[sonarjs.configs.recommended, source],
[regexp.configs.recommended, source],
[security.configs.recommended, source],
[noUnsanitized.configs.recommended, source],
[perfectionist.configs["recommended-natural"], source],
[stylistic.configs.recommended, component],
[react.configs["recommended-type-checked"], component],
[hooks.configs.flat.recommended, component],
[refresh.configs.vite, component],
[query.configs["flat/recommended"], component],
[vitest.configs.recommended, unit],
[testingLibrary.configs["flat/react"], unit],
[jestDom.configs["flat/recommended"], unit],
...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]),
]) {
const configs = [preset].flat();
const expected = Object.assign({}, ...configs.map(config => config.rules));
const config = await eslint.calculateConfigForFile(filePath);
const actual = new Map(Object.entries(config.rules));
for (const [rule, setting] of Object.entries(expected)) {
const severity = Array.isArray(setting) ? setting[0] : setting;
if (severity === "off" || severity === 0 || !rule.includes("/")) continue;
assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`);
}
}
});
test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => {
const eslint = new ESLint({ fix: true });
const filePath = "packages/ui/src/EntryCard/EntryCard.tsx";
const [fixed] = await eslint.lintText(
"//identity\nconst identity = <T,>(value: T): T => value;\nexport {identity};",
{ filePath },
);
assert.equal(fixed.fatalErrorCount, 0);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output);
const [again] = await eslint.lintText(fixed.output, { filePath });
assert.equal(again.errorCount, 0);
assert.equal(again.output, undefined);
});
test("ESLint formatting preserves spaces around JSX text and interpolations", async () => {
const eslint = new ESLint({ fix: true });
const [fixed] = await eslint.lintText(
"const Example = () => {\nconst value = 'value';\nreturn <span>Amount: {value} / {value}</span>;\n};\nexport { Example };",
{ filePath: "packages/ui/src/EntryCard/EntryCard.tsx" },
);
assert.equal(fixed.errorCount, 0);
assert.ok(fixed.output?.includes("Amount: {value} / {value}"));
});
// Single-use codes must be caught too; fixture literals remain independent wire assertions.
test("machine codes require enum members without banning ordinary text or Prisma selects", async () => {
const eslint = new ESLint();
const filePath = "src/shared/api/problem-details.ts";
for (const code of ["export function check(code: string) { return code === \"one-off-code\"; }", "export const result = `MOEX_UNAVAILABLE`;", "export const result = { code: \"one-off-code\" };", "export const result = { \"errorCode\": \"one-off-code\" };", "export const result = { code: `dynamic-${Date.now()}` };", "export const result = \"MOEX_UNAVAILABLE\";", "export const result = [\"INSTRUMENT_ID_TYPE_FIGI\"];", "export const result = { OPERATION_TYPE_COUPON: \"income\" };"]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.some(({ ruleId }) => ruleId === "no-restricted-syntax"), code);
}
for (const code of [
"enum Code { Unavailable = \"MOEX_UNAVAILABLE\" } export const result = { code: Code.Unavailable };",
"export const select = { code: true, errorCode: true };",
"export const label = \"Please try again\"; export const key = \"JWT_SECRET\";",
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0, code);
assert.ok(result.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"), code);
}
const [fixture] = await eslint.lintText("export const result = { code: \"MOEX_UNAVAILABLE\" };", { filePath: "src/shared/api/client.test.ts" });
assert.ok(fixture.messages.every(({ ruleId }) => ruleId !== "no-restricted-syntax"));
const [comparison] = await eslint.lintText("enum Code { Ready = \"ready\" } export function check(code: Code) { return code === \"ready\"; }", { filePath });
assert.ok(comparison.messages.some(({ ruleId }) => ruleId === "@typescript-eslint/no-unsafe-enum-comparison"));
});
+22 -13
View File
@@ -9,53 +9,62 @@ import {
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join, resolve } from "node:path";
import nodePath from "node:path";
import { test } from "node:test";
const GITHUB_TOKEN_RANDOM_BYTES = 18;
test("pre-commit scans the index, redacts findings and fails closed", () => {
const hook = resolve(".husky/pre-commit");
const cwd = mkdtempSync(join(tmpdir(), "frontend-hook-"));
const bin = join(cwd, "bin");
const marker = join(cwd, "downstream");
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");
const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`;
const git = (...args) => execFileSync("git", args, { cwd, stdio: "pipe" });
const run = (path = `${bin}${delimiter}${process.env.PATH}`) =>
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
const git = (...arguments_) => execFileSync("git", arguments_, { cwd, stdio: "pipe" });
const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) =>
spawnSync("/bin/sh", ["-e", hook], {
cwd,
env: { ...process.env, PATH: path },
encoding: "utf8",
env: { ...process.env, PATH: path },
});
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);
for (const command of ["npx", "npm"]) {
writeFileSync(join(bin, command), "#!/bin/sh\necho ran >> downstream\n", {
// 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,
});
}
git("init", "--quiet");
writeFileSync(join(cwd, "sample.txt"), "safe staged content\n");
// 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");
// An unstaged secret must not be mistaken for the index being committed.
writeFileSync(join(cwd, "sample.txt"), `token=${token}\n`);
// 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"), `token=${token}\n`);
let result = run();
assert.equal(
result.status,
0,
"clean index must pass; install Gitleaks first",
);
// 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));
rmSync(marker);
git("add", "sample.txt");
result = run();
assert.equal(result.status, 1, "staged secret must block commit");
assert.ok(!`${result.stdout}${result.stderr}`.includes(token));
// 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), "formatting/dependency checks must not run");
result = run(bin);
assert.notEqual(result.status, 0, "missing Gitleaks must block commit");
// 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));
} finally {
rmSync(cwd, { recursive: true, force: true });
}
finally {
rmSync(cwd, { force: true, recursive: true });
}
});
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import {
@@ -7,95 +9,104 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, useLocation } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import type { BrokerageOperation } from "@/entities/broker-operation";
import type { IncomeAnalytics } from "@/entities/income";
import type { Portfolio } from "@/entities/portfolio";
import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
const accounts = [
{ id: "a", name: "Основной" },
{ id: "b", name: "ИИС" },
];
const position: Portfolio["positions"][number] = {
figi: "SBER",
displayName: "Сбербанк",
type: "STOCK",
quantity: "2",
currentValue: { currency: "RUB", value: "600" },
absoluteReturn: null,
averagePurchasePrice: null,
isin: null,
ticker: "SBER",
logoName: null,
};
const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
absoluteReturn: null,
averagePurchasePrice: null,
currentValue: { currency: "RUB", value: "600" },
displayName: "Сбербанк",
figi: "SBER",
isin: null,
logoName: null,
quantity: "2",
ticker: "SBER",
type: "STOCK",
}
/* eslint-enable unicorn/no-null */);
const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-15T12:00:00Z",
isStale: false,
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
positions: accounts.map((account) => ({ ...position, account })),
isStale: false,
lastSuccessfulAt: "2026-08-15T12:00:00Z",
positions: accounts.map(account => ({ ...position, account })),
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
};
const receipt: BrokerageOperation = {
id: "same",
occurredAt: "2026-08-15T12:00:00Z",
type: "OPERATION_TYPE_COUPON",
category: "income",
description: "Выплата купона",
figi: null,
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
logoName: null,
payment: { currency: "RUB", value: "1" },
quantity: null,
price: null,
accruedInterest: null,
commission: null,
};
const items = accounts.map((account) => ({ ...receipt, account }));
const analytics: IncomeAnalytics = {
accountId: "all",
period: { from: "2026-08-01", to: "2026-08-31" },
calculatedAt: "2026-09-01T12:00:00Z",
totalsByCurrency: [
{
currency: "RUB",
coupon: { currency: "RUB", value: "2" },
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accruedInterest: null,
category: "income",
commission: null,
description: "Выплата купона",
figi: null,
id: "same",
instrumentType: "bond",
instrumentUid: null,
logoName: null,
occurredAt: "2026-08-15T12:00:00Z",
payment: { currency: "RUB", value: "1" },
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
}
/* eslint-enable unicorn/no-null */);
const items = accounts.map(account => ({ ...receipt, account }));
const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: "all",
bucketGranularity: "day",
buckets: [],
calculatedAt: "2026-09-01T12:00:00Z",
entries: items.map(entry => ({ ...entry, instrumentKey: "bond:RUB" })),
instruments: [
{
coupon: { currency: "RUB", value: "2" },
displayName: "Облигация",
dividend: { currency: "RUB", value: "0" },
figi: null,
instrumentType: "bond",
instrumentUid: null,
key: "bond:RUB",
receiptCount: 2,
ticker: "BOND",
total: { currency: "RUB", value: "2" },
},
],
period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
},
],
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
},
yearComparison: null,
bucketGranularity: "day",
buckets: [],
instruments: [
{
key: "bond:RUB",
figi: null,
instrumentUid: null,
ticker: "BOND",
instrumentType: "bond",
displayName: "Облигация",
coupon: { currency: "RUB", value: "2" },
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
receiptCount: 2,
},
],
entries: items.map((entry) => ({ ...entry, instrumentKey: "bond:RUB" })),
};
totalsByCurrency: [
{
coupon: { currency: "RUB", value: "2" },
currency: "RUB",
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
},
],
yearComparison: null,
}
/* eslint-enable unicorn/no-null */);
const CurrentLocation: FC = () => {
const location = useLocation();
return (
@@ -127,55 +138,67 @@ beforeEach(() => {
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test",
user: { id: "user", email: "user@example.com" },
user: { email: "user@example.com", id: "user" },
}),
),
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(
accounts.map((account) => ({
...account,
type: "BROKERAGE",
openedOn: null,
portfolioSummary: null,
})),
resource(
accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...account,
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
}
/* eslint-enable unicorn/no-null */))),
),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(portfolio),
HttpResponse.json(resource(portfolio)),
),
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ params }) =>
HttpResponse.json({
accountId: params.accountId,
items,
hasNext: false,
nextCursor: null,
observedAt: receipt.occurredAt,
}),
HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
accountId: params.accountId,
hasNext: false,
items,
nextCursor: null,
observedAt: receipt.occurredAt,
}
/* eslint-enable unicorn/no-null */)),
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () =>
HttpResponse.json(analytics),
HttpResponse.json(resource(analytics)),
),
http.get("*/api/v1/brokerage-accounts/:accountId/events", () =>
HttpResponse.json({
accountId: "all",
calculatedAt: receipt.occurredAt,
incomeTotalsByCurrency: [],
events: accounts.map((account) => ({
account,
type: "coupon",
date: "2026-09-15",
dateKind: "event",
figi: null,
instrumentUid: null,
ticker: "BOND",
displayName: "Облигация",
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
})),
}),
HttpResponse.json(
resource({
accountId: "all",
calculatedAt: receipt.occurredAt,
events: accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
account,
date: "2026-09-15",
dateKind: "event",
displayName: "Облигация",
figi: null,
instrumentUid: null,
isin: null,
logoName: null,
projectedAmount: { currency: "RUB", value: "2" },
ticker: "BOND",
type: "coupon",
}
/* eslint-enable unicorn/no-null */))),
incomeTotalsByCurrency: [],
}),
),
),
);
});
@@ -212,7 +235,7 @@ describe("All accounts routes", () => {
);
for (const suffix of ["operations", "income", "events"])
expect(
document.querySelector(`a[href="/accounts/all/${suffix}"]`),
screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`),
).toBeInTheDocument();
});
@@ -221,23 +244,27 @@ describe("All accounts routes", () => {
server.use(
http.get(
"*/api/v1/brokerage-accounts/:accountId/operations",
({ request, params }) => {
({ params, request }) => {
const url = new URL(request.url);
requests.push(url);
return HttpResponse.json({
accountId: params.accountId,
items: [
return HttpResponse.json(
resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
...receipt,
description: url.searchParams.has("cursor")
? "Вторая страница"
: "Первая страница",
},
],
hasNext: !url.searchParams.has("cursor"),
nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt,
});
accountId: params.accountId,
hasNext: !url.searchParams.has("cursor"),
items: [
{
...receipt,
description: url.searchParams.has("cursor")
? "Вторая страница"
: "Первая страница",
},
],
nextCursor: url.searchParams.has("cursor") ? null : "next",
observedAt: receipt.occurredAt,
}
/* eslint-enable unicorn/no-null */)),
);
},
),
);
@@ -251,10 +278,10 @@ describe("All accounts routes", () => {
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(
requests.some((url) => url.pathname.includes("/b/operations")),
requests.some(url => url.pathname.includes("/b/operations")),
).toBe(true),
);
const selected = requests.find((url) =>
const selected = requests.find(url =>
url.pathname.includes("/b/operations"),
);
expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z");
@@ -274,13 +301,15 @@ describe("All accounts routes", () => {
{ code: "INVALID_OPERATIONS_CURSOR" },
{ status: 400 },
)
: HttpResponse.json({
accountId: "all",
items,
hasNext: true,
nextCursor: "expired",
observedAt: receipt.occurredAt,
}),
: HttpResponse.json(
resource({
accountId: "all",
hasNext: true,
items,
nextCursor: "expired",
observedAt: receipt.occurredAt,
}),
),
),
);
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
@@ -294,7 +323,7 @@ describe("All accounts routes", () => {
});
it("retains the complete portfolio during a failed refresh", async () => {
const client = renderPage("/accounts/all");
const view = renderPage("/accounts/all");
await screen.findAllByText("Сбербанк");
server.use(
http.get("*/api/v1/brokerage-accounts/all/portfolio", () =>
@@ -302,7 +331,7 @@ describe("All accounts routes", () => {
),
);
await act(() =>
client.invalidateQueries({
view.invalidateQueries({
predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"),
}),
);
@@ -1,34 +1,35 @@
import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { fireEvent, render, screen } from "@testing-library/react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth";
import { cssVariablesResolver, theme } from "@/shared/theme";
import { createTestQueryClient } from "@/test/query";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
describe("AppRoutes", () => {
function renderRoutes(path: string) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</MantineProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
function renderRoutes(path: string) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</MantineProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("AppRoutes", () => {
it.each([
["/", "Вход"],
["/login", "Вход"],
@@ -42,22 +43,18 @@ describe("AppRoutes", () => {
])("renders %s with its page title", async (path, title) => {
renderRoutes(path);
await waitFor(() =>
expect(
screen.getByRole("heading", { level: 1, name: title }),
).toBeInTheDocument(),
);
expect(
await screen.findByRole("heading", { level: 1, name: title }),
).toBeInTheDocument();
expect(document.title).toBe(`${title} — WealthLedger`);
});
it("redirects anonymous users from accounts", async () => {
renderRoutes("/accounts");
await waitFor(() =>
expect(
screen.getByRole("heading", { level: 1, name: "Вход" }),
).toBeInTheDocument(),
);
expect(
await screen.findByRole("heading", { level: 1, name: "Вход" }),
).toBeInTheDocument();
});
it("logs in through the public form and reaches the protected landing", async () => {
@@ -67,7 +64,7 @@ describe("AppRoutes", () => {
credentials = await request.json();
return HttpResponse.json({
accessToken: "login-token",
user: { id: "user-1", email: "user@example.com" },
user: { email: "user@example.com", id: "user-1" },
});
}),
);
@@ -101,19 +98,21 @@ describe("AppRoutes", () => {
http.post("*/api/v1/auth/login", () =>
HttpResponse.json({
accessToken: "test",
user: { id: "user-1", email: "user@example.com" },
user: { email: "user@example.com", id: "user-1" },
}),
),
http.get("*/api/v1/bank-cards", () => HttpResponse.json([])),
http.get("*/api/v1/bank-cards/summary", () =>
HttpResponse.json({
month: "2026-08",
cardId: null,
expenses: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
net: { currency: "RUB", value: "0" },
categories: [],
}),
HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
cardId: null,
categories: [],
expenses: { currency: "RUB", value: "0" },
month: "2026-08",
net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
}
/* eslint-enable unicorn/no-null */)),
),
);
renderRoutes("/cards?month=2026-08");
@@ -137,20 +136,18 @@ describe("AppRoutes", () => {
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" },
user: { email: "user@example.com", id: "user-1" },
}),
),
);
renderRoutes("/accounts");
await waitFor(() =>
expect(
screen.getByRole("heading", {
level: 2,
name: "Нет инвестиционных счетов",
}),
).toBeInTheDocument(),
);
expect(
await screen.findByRole("heading", {
level: 2,
name: "Нет инвестиционных счетов",
}),
).toBeInTheDocument();
expect(
screen.getByRole("navigation", { name: "WealthLedger" }),
).toBeInTheDocument();
@@ -1,8 +1,10 @@
import type { FC } from "react";
import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "./RouteFallback";
import { Protected, Public } from "./RouteGuards";
import { LazyPage } from "../RouteFallback";
import { Protected, Public } from "../RouteGuards";
const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card");
@@ -65,178 +67,178 @@ const SettingsPage = lazy(async () => {
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("./ProtectedLayout");
const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout };
});
const AppRoutes: FC = () => (
<Routes>
<Route path="/" element={<Navigate to="/accounts" replace />} />
<Route element={<Navigate replace to="/accounts" />} path="/" />
<Route
path="/login"
element={
element={(
<Public>
<LazyPage variant="auth">
<LoginPage />
</LazyPage>
</Public>
}
)}
path="/login"
/>
<Route
path="/register"
element={
element={(
<Public>
<LazyPage variant="auth">
<RegisterPage />
</LazyPage>
</Public>
}
)}
path="/register"
/>
<Route
element={
element={(
<Protected>
<LazyPage variant="shell">
<ProtectedLayout />
</LazyPage>
</Protected>
}
)}
>
<Route
path="/cards/:cardId"
element={
element={(
<LazyPage variant="content">
<CardPage />
</LazyPage>
}
)}
path="/cards/:cardId"
/>
<Route
path="/cards"
element={
element={(
<LazyPage variant="content">
<CardsPage />
</LazyPage>
}
)}
path="/cards"
/>
<Route
path="/bonds"
element={
element={(
<LazyPage variant="content">
<BondsPage />
</LazyPage>
}
)}
path="/bonds"
/>
<Route
path="/bonds/:bondId"
element={
element={(
<LazyPage variant="content">
<BondPage />
</LazyPage>
}
)}
path="/bonds/:bondId"
/>
<Route
path="/stocks"
element={
element={(
<LazyPage variant="content">
<StocksPage />
</LazyPage>
}
)}
path="/stocks"
/>
<Route
path="/stocks/:stockId"
element={
element={(
<LazyPage variant="content">
<StockPage />
</LazyPage>
}
)}
path="/stocks/:stockId"
/>
<Route
path="/accounts"
element={
element={(
<LazyPage variant="content">
<AccountsPage />
</LazyPage>
}
)}
path="/accounts"
/>
<Route
path="/accounts/all"
element={
element={(
<LazyPage variant="content">
<AccountPage accountId="all" />
</LazyPage>
}
)}
path="/accounts/all"
/>
<Route
path="/accounts/all/operations"
element={
element={(
<LazyPage variant="content">
<AccountOperationsPage accountId="all" />
</LazyPage>
}
)}
path="/accounts/all/operations"
/>
<Route
path="/accounts/all/income"
element={
element={(
<LazyPage variant="content">
<AccountIncomePage accountId="all" />
</LazyPage>
}
)}
path="/accounts/all/income"
/>
<Route
path="/accounts/all/events"
element={
element={(
<LazyPage variant="content">
<AccountEventsPage accountId="all" />
</LazyPage>
}
)}
path="/accounts/all/events"
/>
<Route
path="/accounts/:accountId"
element={
element={(
<LazyPage variant="content">
<AccountPage />
</LazyPage>
}
)}
path="/accounts/:accountId"
/>
<Route
path="/accounts/:accountId/events"
element={
element={(
<LazyPage variant="content">
<AccountEventsPage />
</LazyPage>
}
)}
path="/accounts/:accountId/events"
/>
<Route
path="/accounts/:accountId/income"
element={
element={(
<LazyPage variant="content">
<AccountIncomePage />
</LazyPage>
}
)}
path="/accounts/:accountId/income"
/>
<Route
path="/accounts/:accountId/operations"
element={
element={(
<LazyPage variant="content">
<AccountOperationsPage />
</LazyPage>
}
)}
path="/accounts/:accountId/operations"
/>
<Route
path="/settings"
element={
element={(
<LazyPage variant="content">
<SettingsPage />
</LazyPage>
}
)}
path="/settings"
/>
</Route>
<Route
path="*"
element={
element={(
<LazyPage variant="content">
<NotFoundPage />
</LazyPage>
}
)}
path="*"
/>
</Routes>
);
@@ -1,3 +1,5 @@
import type { FC } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import {
act,
@@ -6,23 +8,25 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC } from "react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, useNavigate } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { AuthProvider } from "@/features/auth";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { ProtectedDocumentTitle } from "./DocumentTitle";
const Navigation: FC = () => {
const navigate = useNavigate();
return (
<>
<button type="button" onClick={() => navigate("/accounts/all/income")}>
<button onClick={() => navigate("/accounts/all/income")} type="button">
Все счета
</button>
<button type="button" onClick={() => navigate(-1)}>
<button onClick={() => navigate(-1)} type="button">
Назад
</button>
</>
@@ -47,7 +51,7 @@ beforeEach(() => {
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" },
user: { email: "user@example.com", id: "user-1" },
}),
),
);
@@ -55,19 +59,16 @@ beforeEach(() => {
describe("document titles", () => {
it("updates a loading account name and follows account scope and history", async () => {
let resolveAccounts!: () => void;
const accountsReady = new Promise<void>((resolve) => {
resolveAccounts = resolve;
});
const { promise: accountsReady, resolve: resolveAccounts } = Promise.withResolvers<void>();
server.use(
http.get("*/api/v1/brokerage-accounts", async () => {
await accountsReady;
return HttpResponse.json([{ id: "a", name: "ИИС" }]);
return HttpResponse.json(resource([{ id: "a", name: "ИИС" }]));
}),
);
renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31");
expect(document.title).toBe("Пассивный доход — Счет — WealthLedger");
await act(async () => resolveAccounts());
await act(() => Promise.resolve(resolveAccounts()));
await waitFor(() =>
expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"),
);
@@ -85,7 +86,7 @@ describe("document titles", () => {
async (kind, id, fallback, name) => {
server.use(
http.get(`*/api/v1/${kind}/by-ticker/:ticker`, () =>
HttpResponse.json({ name }),
HttpResponse.json(resource({ name })),
),
);
renderTitle(`/${kind}/${id}`);
@@ -97,15 +98,15 @@ describe("document titles", () => {
);
it("keeps a useful title when the instrument is unavailable", async () => {
let requested = false;
let isRequested = false;
server.use(
http.get("*/api/v1/stocks/by-ticker/:ticker", () => {
requested = true;
isRequested = true;
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
}),
);
renderTitle("/stocks/missing");
await waitFor(() => expect(requested).toBe(true));
await waitFor(() => expect(isRequested).toBe(true));
expect(document.title).toBe("Акция — WealthLedger");
});
});
@@ -1,7 +1,9 @@
import { useDocumentTitle } from "@mantine/hooks";
import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
@@ -9,10 +11,9 @@ import { useStock } from "@/entities/stock";
type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props) => {
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`);
return null;
};
type AccountDocumentTitleProps = {
@@ -23,19 +24,29 @@ type AccountDocumentTitleProps = {
const AccountDocumentTitle: FC<AccountDocumentTitleProps> = (props) => {
const { t } = useTranslation();
const { data } = useBrokerageAccounts();
const accountName =
props.accountId === "all"
const accountName
= props.accountId === "all"
? t("accounts.all")
: (data?.find((account) => account.id === props.accountId)?.name ??
t("account.title"));
const section =
props.section === "income"
? t("income.title")
: props.section === "operations"
? t("ledger.title")
: props.section === "events"
? t("events.title")
: t("portfolio.title");
: (data?.find(account => account.id === props.accountId)?.name
?? t("account.title"));
let section = t("portfolio.title");
switch (props.section ?? "") {
case "income": {
section = t("income.title");
break;
}
case "operations": {
section = t("ledger.title");
break;
}
case "events": {
section = t("events.title");
break;
}
default: {
break;
}
}
return <DocumentTitle title={`${section} — ${accountName}`} />;
};
@@ -80,21 +91,10 @@ const ProtectedDocumentTitle: FC = () => {
if (bond) return <BondDocumentTitle identifier={bond.params.bondId ?? ""} />;
if (stock)
return <StockDocumentTitle identifier={stock.params.stockId ?? ""} />;
return (
<DocumentTitle
title={t(
cards
? "navigation.cards"
: bonds
? "navigation.bonds"
: stocks
? "navigation.stocks"
: settings
? "navigation.settings"
: "navigation.accounts",
)}
/>
);
if (cards) return <DocumentTitle title={t("navigation.cards")} />;
if (bonds) return <DocumentTitle title={t("navigation.bonds")} />;
if (stocks) return <DocumentTitle title={t("navigation.stocks")} />;
return settings ? <DocumentTitle title={t("navigation.settings")} /> : <DocumentTitle title={t("navigation.accounts")} />;
};
export { ProtectedDocumentTitle };
+4 -3
View File
@@ -1,7 +1,8 @@
import type { FC } from "react";
import { AppBreadcrumbs } from "@/app/ui";
import { AppShell } from "@/shared/ui";
import { ProtectedDocumentTitle } from "./DocumentTitle";
import { AppBreadcrumbs, AppShell } from "@/app/ui";
import { ProtectedDocumentTitle } from "./DocumentTitle/DocumentTitle";
const ProtectedLayout: FC = () => {
return (
@@ -1,74 +0,0 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, waitFor } from "@testing-library/react";
import type { FC } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
let authenticated = false;
vi.mock("@/features/auth", () => ({
useAuth: () => ({ user: authenticated ? {} : null }),
}));
const ThrowingRoute: FC = () => {
throw new Error("test route failure");
};
const HealthyRoute: FC = () => {
return (
<div>
{useLocation().pathname === "/healthy" ? (
"healthy route"
) : (
<ThrowingRoute />
)}
</div>
);
};
const RouteHarness: FC = () => {
const navigate = useNavigate();
return (
<>
<button type="button" onClick={() => navigate("/healthy")}>
go healthy
</button>
<RouteErrorBoundary>
<HealthyRoute />
</RouteErrorBoundary>
</>
);
};
function renderBoundary(path: string) {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteHarness />
</MemoryRouter>
</MantineProvider>,
);
}
describe("RouteErrorBoundary", () => {
it("offers login after a public route error without error details", () => {
authenticated = false;
renderBoundary("/login");
expect(document.title).toBe("Не удалось открыть страницу — WealthLedger");
expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute(
"href",
"/login",
);
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
});
it("offers accounts after a protected route error and resets on navigation", async () => {
authenticated = true;
renderBoundary("/accounts");
screen.getByRole("button", { name: "go healthy" }).click();
await waitFor(() =>
expect(screen.getByText("healthy route")).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,145 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen } from "@testing-library/react";
import { type FC, lazy, Suspense } from "react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
const session = { isAuthenticated: false };
vi.mock("@/features/auth", () => ({
useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{ user: session.isAuthenticated ? {} : null }
/* eslint-enable unicorn/no-null */)),
}));
const ThrowingRoute: FC = () => {
throw new Error("test route failure");
};
const HealthyRoute: FC = () => {
return (
<div>
{useLocation().pathname === "/healthy"
? (
"healthy route"
)
: (
<ThrowingRoute />
)}
</div>
);
};
const RouteHarness: FC = () => {
const navigate = useNavigate();
return (
<>
<button onClick={() => navigate("/healthy")} type="button">
go healthy
</button>
<RouteErrorBoundary>
<HealthyRoute />
</RouteErrorBoundary>
</>
);
};
function renderBoundary(path: string) {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteHarness />
</MemoryRouter>
</MantineProvider>,
);
}
describe("RouteErrorBoundary", () => {
it.each(["/accounts", "/login"])(
"recovers a transient failure through the fallback on %s itself",
async (path) => {
session.isAuthenticated = path === "/accounts";
let isFailing = true;
const RecoverableRoute: FC = () => {
if (isFailing) throw new Error("temporary failure");
return <div>recovered route</div>;
};
render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteErrorBoundary>
<RecoverableRoute />
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
const action = screen.getByRole("link", { name: "Продолжить" });
isFailing = false;
fireEvent.click(action);
expect(await screen.findByText("recovered route")).toBeInTheDocument();
},
);
it("keeps a persistent failure recoverable without an automatic reset loop", () => {
session.isAuthenticated = true;
renderBoundary("/accounts");
fireEvent.click(screen.getByRole("link", { name: "Продолжить" }));
expect(
screen.getByRole("heading", { name: "Не удалось открыть страницу" }),
).toBeInTheDocument();
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Перезагрузить страницу" }),
).toHaveAttribute("href", "/accounts");
});
it("offers document navigation when React.lazy caches a rejected chunk", async () => {
session.isAuthenticated = false;
const load = vi.fn(() => Promise.reject(new Error("chunk failed")));
const Chunk = lazy(load);
render(
<MantineProvider>
<MemoryRouter initialEntries={["/login"]}>
<RouteErrorBoundary>
<Suspense fallback={<div>loading chunk</div>}>
<Chunk />
</Suspense>
</RouteErrorBoundary>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.click(await screen.findByRole("link", { name: "Продолжить" }));
expect(load).toHaveBeenCalledTimes(1);
const reload = screen.getByRole("link", { name: "Перезагрузить страницу" });
expect(reload).toHaveAttribute("href", "/login");
// Cancel browser navigation in JSDOM only after proving React Router did not intercept it.
let isIntercepted = true;
const preventNavigation = (event: MouseEvent) => {
isIntercepted = event.defaultPrevented;
event.preventDefault();
};
document.addEventListener("click", preventNavigation, { once: true });
fireEvent.click(reload);
expect(isIntercepted).toBe(false);
});
it("offers login after a public route error without error details", () => {
session.isAuthenticated = false;
renderBoundary("/login");
expect(document.title).toBe("Не удалось открыть страницу — WealthLedger");
expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute(
"href",
"/login",
);
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
});
it("offers accounts after a protected route error and resets on navigation", async () => {
session.isAuthenticated = true;
renderBoundary("/accounts");
fireEvent.click(screen.getByRole("button", { name: "go healthy" }));
expect(await screen.findByText("healthy route")).toBeInTheDocument();
});
});
@@ -1,14 +1,16 @@
import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import type { FC, PropsWithChildren } from "react";
import { Component } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
type BoundaryProps = PropsWithChildren<{
routeKey: string;
authenticated: boolean;
routeKey: string;
}>;
type RouteErrorFallbackProps = {
@@ -18,12 +20,12 @@ type RouteErrorFallbackProps = {
type State = { hasError: boolean };
class Boundary extends Component<BoundaryProps, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
state: State = { hasError: false };
componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false });
@@ -31,10 +33,7 @@ class Boundary extends Component<BoundaryProps, State> {
}
render() {
if (this.state.hasError) {
return <RouteErrorFallback authenticated={this.props.authenticated} />;
}
return this.props.children;
return this.state.hasError ? <RouteErrorFallback authenticated={this.props.authenticated} /> : this.props.children;
}
}
@@ -51,17 +50,23 @@ const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
<Button component={Link} to={to}>
{t("routeError.action")}
</Button>
<Button component="a" href={to} variant="default">
{t("routeError.reload")}
</Button>
</Stack>
);
};
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { pathname, search } = useLocation();
const { key, pathname, search } = useLocation();
const { user } = useAuth();
return (
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
<Boundary
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
{children}
</Boundary>
);
+12 -8
View File
@@ -1,11 +1,13 @@
import { Container, Group, Skeleton, Stack } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import { Container, Group, Skeleton, Stack } from "@mantine/core";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { RouteTransition } from "./RouteTransition";
type RouteSkeletonVariant = "auth" | "content" | "shell";
type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>;
type RouteSkeletonVariant = "auth" | "content" | "shell";
const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
const { variant } = props;
@@ -13,7 +15,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => {
if (variant === "auth")
return (
<Container size={420} py="xl">
<Container py="xl" size={420}>
<Stack
aria-busy="true"
aria-label={t("states.loadingTitle")}
@@ -61,11 +63,13 @@ const LazyPage: FC<LazyPageProps> = (props) => {
const { children, variant } = props;
return (
<Suspense fallback={<RouteSkeleton variant={variant} />}>
{variant === "shell" ? (
children
) : (
<RouteTransition>{children}</RouteTransition>
)}
{variant === "shell"
? (
children
)
: (
<RouteTransition>{children}</RouteTransition>
)}
</Suspense>
);
};
+24 -18
View File
@@ -1,37 +1,43 @@
import type { FC, PropsWithChildren } from "react";
import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth();
const { ready, user } = useAuth();
const location = useLocation();
if (!ready) return <RouteSkeleton variant="shell" />;
return user ? (
children
) : (
<Navigate
to="/login"
state={{ returnTo: `${location.pathname}${location.search}` }}
replace
/>
);
return user
? (
<>{children}</>
)
: (
<Navigate
replace
state={{ returnTo: `${location.pathname}${location.search}` }}
to="/login"
/>
);
};
const Public: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { user, ready } = useAuth();
const { ready, user } = useAuth();
const location = useLocation();
const requested = (location.state as { returnTo?: string } | null)?.returnTo;
const returnTo =
typeof requested === "string" &&
requested.startsWith("/") &&
!requested.startsWith("//")
if (!ready) return <RouteSkeleton variant="shell" />;
const requested = (location.state as null | { returnTo?: string })?.returnTo;
const returnTo
= typeof requested === "string"
&& requested.startsWith("/")
&& !requested.startsWith("//")
? requested
: "/accounts";
if (!ready) return <RouteSkeleton variant="shell" />;
return user ? <Navigate to={returnTo} replace /> : children;
return user ? <Navigate replace to={returnTo} /> : <>{children}</>;
};
export { Protected, Public };
+4 -3
View File
@@ -1,6 +1,7 @@
import type { FC, PropsWithChildren } from "react";
import { domAnimation, LazyMotion, useReducedMotion } from "motion/react";
import * as m from "motion/react-m";
import type { FC, PropsWithChildren } from "react";
import { useLocation } from "react-router";
const TRANSITION_DURATION_SECONDS = 0.2;
@@ -13,9 +14,9 @@ const RouteTransition: FC<PropsWithChildren> = (props) => {
return (
<LazyMotion features={domAnimation}>
<m.div
key={pathname}
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
initial={!reduceMotion && { opacity: 0 }}
key={pathname}
transition={{
duration: reduceMotion ? 0 : TRANSITION_DURATION_SECONDS,
ease: "easeOut",
+2 -2
View File
@@ -1,2 +1,2 @@
export { AppRoutes } from "./AppRoutes";
export { RouteErrorBoundary } from "./RouteErrorBoundary";
export { AppRoutes } from "./AppRoutes/AppRoutes";
export { RouteErrorBoundary } from "./RouteErrorBoundary/RouteErrorBoundary";
+5 -3
View File
@@ -1,11 +1,13 @@
import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { FC } from "react";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { BrowserRouter } from "react-router";
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
import { AuthProvider } from "@/features/auth";
import { cssVariablesResolver, theme } from "@/shared/theme";
const queryClient = new QueryClient({
defaultOptions: {
@@ -17,8 +19,8 @@ const App: FC = () => {
return (
<MantineProvider
cssVariablesResolver={cssVariablesResolver}
theme={theme}
forceColorScheme="light"
theme={theme}
>
<DatesProvider settings={{ locale: "ru" }}>
<QueryClientProvider client={queryClient}>
@@ -1,6 +1,9 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type AccountBreadcrumbsProps = {
@@ -12,11 +15,11 @@ const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
const { accountId, section } = props;
const { data: accounts } = useBrokerageAccounts();
const { t } = useTranslation();
const accountName =
accountId === "all"
const accountName
= accountId === "all"
? t("accounts.all")
: (accounts?.find((account) => account.id === accountId)?.name ??
t("breadcrumbs.accountFallback"));
: (accounts?.find(account => account.id === accountId)?.name
?? t("breadcrumbs.accountFallback"));
const accountPath = `/accounts/${accountId}`;
const items = [
{ label: t("breadcrumbs.accounts"), to: "/accounts" },
@@ -1,7 +1,9 @@
import { Select } from "@mantine/core";
import type { FC } from "react";
import { Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
type AccountScopeSwitchProps = { accountId: string; section?: string };
@@ -13,31 +15,31 @@ const AccountScopeSwitch: FC<AccountScopeSwitchProps> = (props) => {
const navigate = useNavigate();
const { search } = useLocation();
const data = [
{ value: "all", label: t("accounts.all") },
...(accounts ?? []).map((account) => ({
value: account.id,
{ label: t("accounts.all"), value: "all" },
...(accounts ?? []).map(account => ({
label: account.name,
value: account.id,
})),
];
if (!data.some((item) => item.value === accountId))
data.push({ value: accountId, label: t("breadcrumbs.accountFallback") });
if (data.every(item => item.value !== accountId))
data.push({ label: t("breadcrumbs.accountFallback"), value: accountId });
return (
<Select
label={t("accounts.scope")}
value={accountId}
data={data}
allowDeselect={false}
data={data}
label={t("accounts.scope")}
maw={360}
w="100%"
onChange={(value) => {
if (!value || value === accountId) return;
const filters = new URLSearchParams(search);
filters.delete("cursor");
navigate({
pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`,
void navigate({
pathname: ["/accounts", encodeURIComponent(value), section].filter(Boolean).join("/"),
search: filters.toString(),
});
}}
value={accountId}
w="100%"
/>
);
};
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AppBreadcrumbs } from "./AppBreadcrumbs";
const { useBrokerageAccounts } = vi.hoisted(() => ({
@@ -1,7 +1,9 @@
import { Stack } from "@mantine/core";
import type { FC } from "react";
import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useMatch } from "react-router";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
@@ -12,19 +14,19 @@ import { StockBreadcrumbs } from "./StockBreadcrumbs";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
const operations = useMatch({
path: "/accounts/:accountId/operations",
end: true,
path: "/accounts/:accountId/operations",
});
const events = useMatch({ path: "/accounts/:accountId/events", end: true });
const income = useMatch({ path: "/accounts/:accountId/income", end: true });
const account = useMatch({ path: "/accounts/:accountId", end: true });
const events = useMatch({ end: true, path: "/accounts/:accountId/events" });
const income = useMatch({ end: true, path: "/accounts/:accountId/income" });
const account = useMatch({ end: true, path: "/accounts/:accountId" });
const card = useMatch("/cards/:cardId");
const cards = useMatch("/cards");
const accounts = useMatch({ path: "/accounts", end: true });
const settings = useMatch({ path: "/settings", end: true });
const accounts = useMatch({ end: true, path: "/accounts" });
const settings = useMatch({ end: true, path: "/settings" });
const stock = useMatch({ path: "/stocks/:stockId", end: true });
const bond = useMatch({ path: "/bonds/:bondId", end: true });
const stock = useMatch({ end: true, path: "/stocks/:stockId" });
const bond = useMatch({ end: true, path: "/bonds/:bondId" });
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
@@ -78,9 +80,7 @@ const AppBreadcrumbs: FC = () => {
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
if (settings)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />;
return null;
return settings ? <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} /> : undefined;
};
export { AppBreadcrumbs };
@@ -1,6 +1,9 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
@@ -1,11 +1,13 @@
import { Breadcrumbs } from "@mantine/core";
import type { FC } from "react";
import { Breadcrumbs } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import classes from "./AppBreadcrumbs.module.css";
type Crumb = { label: string; to?: string };
type BreadcrumbTrailProps = { items: Crumb[] };
type Crumb = { label: string; to?: string };
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
const { items } = props;
@@ -18,16 +20,18 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
data-depth={items.length}
separator={<span aria-hidden="true">→</span>}
>
{items.map((item) =>
item.to ? (
<Link className={classes.link} key={item.to} to={item.to}>
{item.label}
</Link>
) : (
<span aria-current="page" key={item.label}>
{item.label}
</span>
),
{items.map(item =>
item.to
? (
<Link className={classes.link} key={item.to} to={item.to}>
{item.label}
</Link>
)
: (
<span aria-current="page" key={item.label}>
{item.label}
</span>
),
)}
</Breadcrumbs>
);
@@ -1,6 +1,9 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
@@ -1,6 +1,9 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
@@ -1,8 +1,11 @@
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import type { FC, ReactNode } from "react";
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "../lib";
import { bindIconAnimation } from "@/shared/lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
@@ -15,9 +18,6 @@ const AppShell: FC<AppShellProps> = (props) => {
const { pathname } = useLocation();
const items = [
{
to: "/accounts",
label: t("navigation.accounts"),
iconClassName: classes.portfolioIcon,
icon: (
<>
<path d="M3 10v9h18v-9" />
@@ -32,11 +32,11 @@ const AppShell: FC<AppShellProps> = (props) => {
/>
</>
),
iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"),
to: "/accounts",
},
{
to: "/cards",
label: t("navigation.cards"),
iconClassName: classes.cardsIcon,
icon: (
<>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
@@ -47,42 +47,42 @@ const AppShell: FC<AppShellProps> = (props) => {
</g>
</>
),
iconClassName: classes.cardsIcon,
label: t("navigation.cards"),
to: "/cards",
},
{
to: "/stocks",
label: t("navigation.stocks"),
iconClassName: classes.stocksIcon,
icon: (
<>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
pathLength={1}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</>
),
iconClassName: classes.stocksIcon,
label: t("navigation.stocks"),
to: "/stocks",
},
{
to: "/bonds",
label: t("navigation.bonds"),
iconClassName: classes.bondsIcon,
icon: (
<>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path pathLength={1} d="M9 7h6" />
<path pathLength={1} d="M9 11h6" />
<path pathLength={1} d="M9 15h3" />
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</>
),
iconClassName: classes.bondsIcon,
label: t("navigation.bonds"),
to: "/bonds",
},
{
to: "/settings",
label: t("navigation.settings"),
iconClassName: classes.settingsIcon,
icon: (
<>
<path d="M4 7h16M4 17h16" />
@@ -90,48 +90,51 @@ const AppShell: FC<AppShellProps> = (props) => {
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</>
),
iconClassName: classes.settingsIcon,
label: t("navigation.settings"),
to: "/settings",
},
];
return (
<MantineAppShell
header={{ height: 60 }}
navbar={{ width: 220, breakpoint: "sm", collapsed: { mobile: true } }}
navbar={{ breakpoint: "sm", collapsed: { mobile: true }, width: 220 }}
padding="md"
>
<MantineAppShell.Header className={classes.header}>
<Text fw={700}>{t("appName")}</Text>
</MantineAppShell.Header>
<MantineAppShell.Navbar p="sm">
{items.map((item) => (
{items.map(item => (
<NavLink
component={RouterNavLink}
active={pathname.startsWith(item.to)}
className={classes.navLink}
component={RouterNavLink}
key={item.to}
to={item.to}
label={item.label}
leftSection={
leftSection={(
<span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
>
<svg
ref={bindIconAnimation}
width={20}
height={20}
viewBox="0 0 24 24"
aria-hidden="true"
fill="none"
focusable="false"
height={20}
ref={bindIconAnimation}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={20}
>
{item.icon}
</svg>
</span>
}
active={pathname.startsWith(item.to)}
)}
to={item.to}
/>
))}
</MantineAppShell.Navbar>
@@ -141,11 +144,11 @@ const AppShell: FC<AppShellProps> = (props) => {
<Outlet />
</div>
</MantineAppShell.Main>
<nav className={classes.bottomNav} aria-label={t("appName")}>
{items.map((item) => (
<nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map(item => (
<RouterNavLink
className={
pathname.startsWith(item.to) ? classes.active : undefined
(pathname.startsWith(item.to)) ? (classes.active) : undefined
}
key={item.to}
to={item.to}
+2 -1
View File
@@ -1 +1,2 @@
export { AppBreadcrumbs } from "./AppBreadcrumbs";
export { AppBreadcrumbs } from "./AppBreadcrumbs/AppBreadcrumbs";
export { AppShell } from "./AppShell/AppShell";
+71 -69
View File
@@ -1,9 +1,11 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import { http, HttpResponse } from "msw";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { apiClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { server } from "@/test/server";
import { useBankMutations } from "./mutations";
import {
type BankCard,
@@ -17,32 +19,34 @@ import {
useBankSummary,
} from "./queries";
const card: BankCard = {
id: "a",
name: "Daily",
bankName: null,
lastFour: null,
paymentSystem: null,
openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-09-01",
balance: { currency: "RUB", value: "100" },
archivedAt: null,
version: 1,
};
const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */
{
archivedAt: null,
balance: { currency: "RUB", value: "100" },
bankName: null,
id: "a",
lastFour: null,
name: "Daily",
openingBalance: { currency: "RUB", value: "100" },
openingDate: "2026-09-01",
paymentSystem: null,
version: 1,
}
/* eslint-enable unicorn/no-null */);
const transfer: CreateBankOperation = {
amount: { currency: "RUB", value: "90071992547409.93" },
cardId: "a",
date: "2026-09-12",
id: "4e6caa65-dd52-4629-81e2-ce8a139a0612",
kind: "transfer",
cardId: "a",
targetCardId: "b",
amount: { currency: "RUB", value: "90071992547409.93" },
date: "2026-09-12",
};
function authenticate() {
configureAuthClient({
accessToken: () => "token",
refresh: async () => true,
clear: vi.fn(),
refresh: () => Promise.resolve(true),
});
}
@@ -59,30 +63,28 @@ function readHandlers(counts: Map<string, number>, categoryName = "Food") {
counts.set(key, (counts.get(key) ?? 0) + 1);
if (url.pathname.endsWith("/categories"))
return HttpResponse.json([
{ id: "food", name: categoryName, isDefault: true, version: 1 },
{ id: "food", isDefault: true, name: categoryName, version: 1 },
]);
if (url.pathname.endsWith("/summary"))
return HttpResponse.json({
month: url.searchParams.get("month"),
cardId: url.searchParams.get("cardId"),
expenses: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
net: { currency: "RUB", value: "0" },
categories: [],
expenses: { currency: "RUB", value: "0" },
month: url.searchParams.get("month"),
net: { currency: "RUB", value: "0" },
refunds: { currency: "RUB", value: "0" },
});
if (
url.pathname.endsWith("/operations") ||
url.pathname.endsWith("/expenses")
url.pathname.endsWith("/operations")
|| url.pathname.endsWith("/expenses")
)
return HttpResponse.json({
hasNext: false,
items: [],
page: 1,
total: 0,
hasNext: false,
});
if (url.pathname.includes("/operations/"))
return HttpResponse.json({ ...transfer, version: 1 });
return HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
return url.pathname.includes("/operations/") ? HttpResponse.json({ ...transfer, version: 1 }) : HttpResponse.json({ ...card, id: url.pathname.split("/").at(-1) });
}),
];
}
@@ -93,15 +95,16 @@ function useReadModels() {
const b = useBankCard("b");
const categories = useBankCategories();
const history = useBankOperations("a", {
limit: 20,
month: "2026-09",
page: 2,
limit: 20,
});
const expenses = useBankExpenses("a", { page: 1 });
const september = useBankSummary("2026-09");
const august = useBankSummary("2026-08", "b");
const original = useBankOperation("original");
return {
mutations: useBankMutations(),
reads: [
cards,
a,
@@ -113,7 +116,6 @@ function useReadModels() {
august,
original,
],
mutations: useBankMutations(),
};
}
@@ -122,7 +124,7 @@ describe("banking API", () => {
it("loads every page for all mode and fails without exposing partial results", async () => {
const pages: number[] = [];
let fail = false;
let isFail = false;
server.use(
http.get("*/api/v1/bank-cards/a/operations", ({ request }) => {
const url = new URL(request.url);
@@ -130,33 +132,33 @@ describe("banking API", () => {
pages.push(page);
expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("month")).toBe("2026-09");
if (fail && page === 2)
if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({
items: [{ ...transfer, id: String(page) }],
total: 2,
page,
hasNext: page === 1,
items: [{ ...transfer, id: String(page) }],
page,
total: 2,
});
}),
);
const { result } = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(pages).toEqual([1, 2]);
expect(result.current.data?.items.map((item) => item.id)).toEqual([
expect(result.current.data?.items.map(item => item.id)).toEqual([
"1",
"2",
]);
fail = true;
const failed = renderHook(
() => useBankOperations("a", { month: "2026-09", page: 1, limit: "all" }),
isFail = true;
const { result: failedResult } = renderHook(
() => useBankOperations("a", { limit: "all", month: "2026-09", page: 1 }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(failed.result.current.isError).toBe(true));
expect(failed.result.current.data).toBeUndefined();
await waitFor(() => expect(failedResult.current.isError).toBe(true));
expect(failedResult.current.data).toBeUndefined();
});
it("uses session-aware banking endpoints and explicit month/page without T-Bank", async () => {
@@ -166,29 +168,29 @@ describe("banking API", () => {
wrapper: createQueryWrapper().wrapper,
});
await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true),
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
);
expect([...counts.keys()]).toContain(
expect(counts.keys()).toContain(
"/api/v1/bank-cards?includeArchived=true",
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards/a/operations?month=2026-09&page=2&limit=20",
expect(counts.keys()).toContain(
"/api/v1/bank-cards/a/operations?limit=20&month=2026-09&page=2",
);
expect([...counts.keys()]).toContain(
"/api/v1/bank-cards/summary?month=2026-08&cardId=b",
expect(counts.keys()).toContain(
"/api/v1/bank-cards/summary?cardId=b&month=2026-08",
);
expect(counts.size).toBe(9);
});
it("replays the exact UUID and large Money string once across shared auth refresh", async () => {
let token = "expired";
const refresh = vi.fn(async () => {
const refresh = vi.fn(() => {
token = "renewed";
return true;
return Promise.resolve(true);
});
configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() });
configureAuthClient({ accessToken: () => token, clear: vi.fn(), refresh });
const bodies: unknown[] = [];
const headers: (string | null)[] = [];
const headers: (null | string)[] = [];
server.use(
http.post("*/api/v1/bank-cards/operations", async ({ request }) => {
bodies.push(await request.json());
@@ -232,10 +234,8 @@ describe("banking API", () => {
it("keeps the confirmed balance while a mutation is pending or fails, and on refetch failure", async () => {
server.use(...readHandlers(new Map()));
let finish: () => void = () => {};
const pending = new Promise<void>((resolve) => {
finish = resolve;
});
const { promise: pending, resolve: finish } = Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/bank-cards/operations", async () => {
await pending;
@@ -243,7 +243,7 @@ describe("banking API", () => {
}),
);
const { result } = renderHook(
() => ({ query: useBankCard("a"), mutations: useBankMutations() }),
() => ({ mutations: useBankMutations(), query: useBankCard("a") }),
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.query.isSuccess).toBe(true));
@@ -251,7 +251,8 @@ describe("banking API", () => {
act(() => {
saving = result.current.mutations.createOperation
.mutateAsync(transfer)
.catch(() => undefined);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
.catch(() => {});
});
await waitFor(() =>
expect(result.current.mutations.createOperation.isPending).toBe(true),
@@ -280,17 +281,18 @@ describe("banking API", () => {
HttpResponse.json({ ...transfer, version: 1 }),
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { queryClient, wrapper } = createQueryWrapper();
const brokerKey = ["get", "/api/v1/brokerage-accounts"];
queryClient.setQueryData(brokerKey, []);
const { result } = renderHook(useReadModels, { wrapper });
await waitFor(() =>
expect(result.current.reads.every((read) => read.isSuccess)).toBe(true),
expect(result.current.reads.every(read => read.isSuccess)).toBe(true),
);
await act(() =>
result.current.mutations.createOperation.mutateAsync(transfer),
);
expect([...counts.values()]).toEqual(Array(9).fill(2));
expect(counts.size).toBe(9);
for (const count of counts.values()) expect(count).toBe(2);
expect(queryClient.getQueryState(brokerKey)?.isInvalidated).toBe(false);
});
@@ -316,8 +318,8 @@ describe("banking API", () => {
await act(async () => {
await expect(
result.current.mutations.updateCard.mutateAsync({
id: "a",
body: { name: "changed", version: 1 },
id: "a",
}),
).rejects.toEqual({ code });
});
@@ -329,7 +331,7 @@ describe("banking API", () => {
let name = "Food";
server.use(
http.get("*/api/v1/bank-cards/categories", () =>
HttpResponse.json([{ id: "food", name, isDefault: true, version: 1 }]),
HttpResponse.json([{ id: "food", isDefault: true, name, version: 1 }]),
),
http.patch("*/api/v1/bank-cards/categories/food", async ({ request }) => {
const body = await request.json();
@@ -337,8 +339,8 @@ describe("banking API", () => {
name = "Groceries";
return HttpResponse.json({
id: "food",
name,
isDefault: true,
name,
version: 2,
});
}),
@@ -366,8 +368,8 @@ describe("banking API", () => {
);
await act(() =>
result.current.mutations.updateCategory.mutateAsync({
id: "food",
body: { name: "Groceries", version: 1 },
id: "food",
}),
);
await waitFor(() =>
@@ -375,8 +377,8 @@ describe("banking API", () => {
);
await act(() =>
result.current.mutations.updateOperation.mutateAsync({
id: "original",
body: { description: "corrected", version: 1 },
id: "original",
}),
);
await act(() =>
@@ -403,8 +405,8 @@ describe("banking API", () => {
}),
),
);
const { wrapper, queryClient } = createQueryWrapper();
const { result, rerender } = renderHook(() => useBankCard("a"), {
const { queryClient, wrapper } = createQueryWrapper();
const { rerender, result } = renderHook(() => useBankCard("a"), {
wrapper,
});
await waitFor(() => expect(result.current.data?.balance.value).toBe("100"));
@@ -413,7 +415,7 @@ describe("banking API", () => {
});
expect(result.current.data?.balance.value).toBe("100");
act(() => {
configureAuthClient(null);
configureAuthClient();
queryClient.clear();
});
rerender();
+35 -29
View File
@@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient, apiErrorCode } from "@/shared/api";
import { apiClient, ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import type {
CreateBankCard,
CreateBankOperation,
@@ -13,6 +15,7 @@ async function mutationData<T>(
request: Promise<{ data?: T; error?: unknown }>,
) {
const { data, error } = await request;
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
return data;
}
@@ -21,59 +24,62 @@ function useBankMutations() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({
predicate: ({ queryKey: [method, path] }) =>
method === "get" &&
typeof path === "string" &&
(path === "/api/v1/bank-cards" ||
path.startsWith("/api/v1/bank-cards/")),
predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return method === "get"
&& typeof path === "string"
&& (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"));
},
});
const options = {
retry: false,
onSuccess: invalidate,
onError: (error: unknown) => {
const errorCode = apiErrorCode(error);
if (
[
"BANK_VERSION_CONFLICT",
"BANK_WRITE_CONFLICT",
"BANK_INTENT_CONFLICT",
"BANK_REFUND_LIMIT",
"BANK_DEPENDENT_REFUNDS",
].includes(apiErrorCode(error) ?? "")
ApiErrorCode.BankDependentRefunds,
ApiErrorCode.BankIntentConflict,
ApiErrorCode.BankRefundLimit,
ApiErrorCode.BankVersionConflict,
ApiErrorCode.BankWriteConflict,
].some(code => isApiErrorCode(errorCode, code))
)
return invalidate();
},
onSuccess: invalidate,
retry: false,
} as const;
const createCard = useMutation({
const cardCreation = useMutation({
...options,
mutationFn: (body: CreateBankCard) =>
mutationData(apiClient.POST("/api/v1/bank-cards", { body })),
});
const updateCard = useMutation({
...options,
mutationFn: ({ id, body }: { id: string; body: UpdateBankCard }) =>
mutationFn: ({ body, id }: { body: UpdateBankCard; id: string }) =>
mutationData(
apiClient.PATCH("/api/v1/bank-cards/{id}", {
params: { path: { id } },
body,
params: { path: { id } },
}),
),
});
const createOperation = useMutation({
const operationCreation = useMutation({
...options,
mutationFn: (body: CreateBankOperation) =>
mutationData(apiClient.POST("/api/v1/bank-cards/operations", { body })),
});
const updateOperation = useMutation({
...options,
mutationFn: ({ id, body }: { id: string; body: UpdateBankOperation }) =>
mutationFn: ({ body, id }: { body: UpdateBankOperation; id: string }) =>
mutationData(
apiClient.PATCH("/api/v1/bank-cards/operations/{id}", {
params: { path: { id } },
body,
params: { path: { id } },
}),
),
});
const deleteOperation = useMutation({
const operationDeletion = useMutation({
...options,
mutationFn: ({ id, version }: { id: string; version: number }) =>
mutationData(
@@ -82,29 +88,29 @@ function useBankMutations() {
}),
),
});
const createCategory = useMutation({
const categoryCreation = useMutation({
...options,
mutationFn: (body: CreateExpenseCategory) =>
mutationData(apiClient.POST("/api/v1/bank-cards/categories", { body })),
});
const updateCategory = useMutation({
...options,
mutationFn: ({ id, body }: { id: string; body: UpdateExpenseCategory }) =>
mutationFn: ({ body, id }: { body: UpdateExpenseCategory; id: string }) =>
mutationData(
apiClient.PATCH("/api/v1/bank-cards/categories/{id}", {
params: { path: { id } },
body,
params: { path: { id } },
}),
),
});
return {
createCard,
createCard: cardCreation,
createCategory: categoryCreation,
createOperation: operationCreation,
deleteOperation: operationDeletion,
updateCard,
createOperation,
updateOperation,
deleteOperation,
createCategory,
updateCategory,
updateOperation,
};
}
+125 -122
View File
@@ -7,39 +7,43 @@ import {
import { moneyCents } from "../model/form-validation";
type BankCard =
paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation =
paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations =
paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary =
paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type ExpenseCategory =
paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type CreateBankCard =
paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type UpdateBankCard =
paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type CreateBankOperation =
paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation =
paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory =
paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory =
paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type BankCard
= paths["/api/v1/bank-cards/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["get"]["responses"][200]["content"]["application/json"];
type BankOperations
= paths["/api/v1/bank-cards/{id}/operations"]["get"]["responses"][200]["content"]["application/json"];
type BankSummary
= paths["/api/v1/bank-cards/summary"]["get"]["responses"][200]["content"]["application/json"];
type CreateBankCard
= paths["/api/v1/bank-cards"]["post"]["requestBody"]["content"]["application/json"];
type CreateBankOperation
= paths["/api/v1/bank-cards/operations"]["post"]["requestBody"]["content"]["application/json"];
type CreateExpenseCategory
= paths["/api/v1/bank-cards/categories"]["post"]["requestBody"]["content"]["application/json"];
type ExpenseCategory
= paths["/api/v1/bank-cards/categories"]["get"]["responses"][200]["content"]["application/json"][number];
type UpdateBankCard
= paths["/api/v1/bank-cards/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateBankOperation
= paths["/api/v1/bank-cards/operations/{id}"]["patch"]["requestBody"]["content"]["application/json"];
type UpdateExpenseCategory
= paths["/api/v1/bank-cards/categories/{id}"]["patch"]["requestBody"]["content"]["application/json"];
const OPERATIONS_PAGE_SIZE = 100;
function useBankCards(includeArchived = false) {
return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: {
query: { includeArchived: includeArchived ? "true" : "false" },
},
function sortBankSummary(data: BankSummary): BankSummary {
return {
...data,
categories: data.categories.toSorted((a, b) => {
const left = moneyCents(a.net.value);
const right = moneyCents(b.net.value);
if (left === null || right === null)
throw new Error("Invalid bank summary amount");
if (left > right) return -1;
return left < right ? 1 : 0;
}),
);
};
}
function useBankCard(id: string | undefined) {
@@ -51,77 +55,25 @@ function useBankCard(id: string | undefined) {
});
}
function useBankCards(isIncludeArchived = false) {
return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: {
query: { includeArchived: isIncludeArchived ? "true" : "false" },
},
}),
);
}
function useBankCategories() {
return useAuthenticatedQuery(
apiQueryClient.queryOptions("get", "/api/v1/bank-cards/categories"),
);
}
function useBankOperations(
id: string | undefined,
query: {
month?: string;
from?: string;
to?: string;
page: number;
limit?: number | "all";
},
) {
const all = query.limit === "all";
const params = {
path: { id: id ?? "" },
query: {
...query,
page: all ? 1 : query.page,
limit: query.limit === "all" ? OPERATIONS_PAGE_SIZE : query.limit,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params },
);
return useAuthenticatedQuery({
queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey,
placeholderData: (
previousData: BankOperations | undefined,
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !all &&
previousQuery?.queryKey.length === options.queryKey.length &&
previous?.path?.id === id &&
previous?.query?.month === query.month &&
previous?.query?.from === query.from &&
previous?.query?.to === query.to &&
previous?.query?.limit === query.limit
? previousData
: undefined;
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = params.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
{
params: { ...params, query: { ...params.query, page } },
signal,
},
);
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
if (!all) return data;
items.push(...data.items);
if (!data.hasNext) return { ...data, items, page: 1, hasNext: false };
}
},
enabled: Boolean(id && (query.month || (query.from && query.to))),
});
}
function useBankExpenses(
id: string | undefined,
query: { page: number; limit?: number },
query: { limit?: number; page: number },
) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/{id}/expenses", {
@@ -131,36 +83,6 @@ function useBankExpenses(
});
}
function sortBankSummary(data: BankSummary): BankSummary {
return {
...data,
categories: data.categories.toSorted((a, b) => {
const left = moneyCents(a.net.value);
const right = moneyCents(b.net.value);
if (left === null || right === null)
throw new Error("Invalid bank summary amount");
return left > right ? -1 : left < right ? 1 : 0;
}),
};
}
function useBankSummary(
period: string | { from: string; to: string },
cardId?: string,
) {
const query =
typeof period === "string"
? { month: period, cardId }
: { ...period, cardId };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query },
}),
select: sortBankSummary,
enabled: Boolean(period),
});
}
function useBankOperation(id: string | undefined) {
return useAuthenticatedQuery({
...apiQueryClient.queryOptions(
@@ -172,6 +94,87 @@ function useBankOperation(id: string | undefined) {
});
}
function useBankOperations(
id: string | undefined,
query: {
from?: string;
limit?: "all" | number;
month?: string;
page: number;
to?: string;
},
) {
const isAll = query.limit === "all";
const parameters = {
path: { id: id ?? "" },
query: {
...query,
limit: query.limit === "all" ? OPERATIONS_PAGE_SIZE : query.limit,
page: isAll ? 1 : query.page,
},
};
const options = apiQueryClient.queryOptions(
"get",
"/api/v1/bank-cards/{id}/operations",
{ params: parameters },
);
// eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity.
return useAuthenticatedQuery({
enabled: Boolean(id && (query.month || (query.from && query.to))),
placeholderData: (
previousData: BankOperations | undefined,
previousQuery,
) => {
const previous = previousQuery?.queryKey[2]?.params;
return !isAll
&& previousQuery?.queryKey.length === options.queryKey.length
&& previous?.path?.id === id
&& previous?.query?.month === query.month
&& previous?.query?.from === query.from
&& previous?.query?.to === query.to
&& previous?.query?.limit === query.limit
? previousData
: undefined;
},
queryFn: async ({ signal }) => {
const items: BankOperation[] = [];
for (let page = parameters.query.page; ; page++) {
const { data, error } = await apiClient.GET(
"/api/v1/bank-cards/{id}/operations",
{
params: { ...parameters, query: { ...parameters.query, page } },
signal,
},
);
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
if (error) throw error;
if (!data) throw new Error("Missing bank operations response");
if (!isAll) return data;
items.push(...data.items);
if (!data.hasNext) return { ...data, hasNext: false, items, page: 1 };
}
},
queryKey: isAll ? ([...options.queryKey, "all"] as const) : options.queryKey,
});
}
function useBankSummary(
period: string | { from: string; to: string },
cardId?: string,
) {
const query
= typeof period === "string"
? { cardId, month: period }
: { ...period, cardId };
return useAuthenticatedQuery({
...apiQueryClient.queryOptions("get", "/api/v1/bank-cards/summary", {
params: { query },
}),
enabled: Boolean(period),
select: sortBankSummary,
});
}
export {
type BankCard,
type BankOperation,
+11 -8
View File
@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { type BankSummary, sortBankSummary } from "./queries";
const money = (value: string) => ({ currency: "RUB", value });
it("orders displayed expenses descending, preserving cents, ties and source data", () => {
const values = [
"-10",
@@ -10,23 +13,23 @@ it("orders displayed expenses descending, preserving cents, ties and source data
"-2",
"0",
];
const money = (value: string) => ({ currency: "RUB", value });
const data: BankSummary = {
month: "2026-09",
cardId: "a",
expenses: money("0"),
refunds: money("0"),
net: money("0"),
categories: values.map((value, index) => ({
categoryId: String(index),
categoryName: String(index),
expenses: money("100"),
refunds: money("0"),
net: money(value),
refunds: money("0"),
})),
expenses: money("0"),
month: "2026-09",
net: money("0"),
refunds: money("0"),
};
expect(sortBankSummary(data).categories.map((row) => row.categoryId)).toEqual(
expect(sortBankSummary(data).categories.map(row => row.categoryId)).toEqual(
["3", "1", "2", "5", "4", "0"],
);
expect(data.categories.map((row) => row.net.value)).toEqual(values);
expect(data.categories.map(row => row.net.value)).toEqual(values);
});
+6 -5
View File
@@ -18,12 +18,13 @@ export {
moneyCents,
positiveMoneySchema,
} from "./model/form-validation";
export { bankOperationKinds } from "./model/operation-kinds";
export { bankPeriod } from "./model/period";
export { BankCardFace } from "./ui/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm";
export { BankCardFace } from "./ui/BankCardFace/BankCardFace";
export { BankCardForm } from "./ui/BankCardForm/BankCardForm";
export { BankFeedback } from "./ui/BankFeedback";
export { BankOperationIcon } from "./ui/BankOperationIcon";
export { BankOperationIcon } from "./ui/BankOperationIcon/BankOperationIcon";
export { BankPeriodPicker } from "./ui/BankPeriodPicker";
export { BankSummaryView } from "./ui/BankSummary.view";
export { CategoryManager } from "./ui/CategoryManager";
export { BankSummaryView } from "./ui/BankSummary/BankSummary.view";
export { CategoryManager } from "./ui/CategoryManager/CategoryManager";
export { useBankFormFocus } from "./ui/useBankFormFocus";
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
bankCardSchema,
bankDateInputValue,
@@ -27,9 +28,9 @@ describe("bank form input", () => {
}
expect(bankMoneyValue(" -00010,20 ")).toBe("-10.20");
const values = {
name: "A",
bankName: "",
lastFour: "",
name: "A",
openingBalance: "-92233720368547758.08",
openingDate: "2026-01-01",
};
+44 -39
View File
@@ -1,38 +1,43 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
const DECIMAL_PART_COUNT = 2;
const CENTS_PRECISION = 2;
const ISO_DATE_LENGTH = 10;
const ISO_MONTH_LENGTH = 7;
const MAX_CARD_NAME_LENGTH = 100;
function bankToday() {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Moscow",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
}
function bankDateInputValue(value: string) {
return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1");
}
function bankMonth(value: string | null) {
return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
function bankMoneyValue(value: string) {
return value
.trim()
.replace(",", ".")
.replace(/^(-?)0+(?=\d)/, "$1");
}
function bankMonth(value: null | string) {
return value && value >= "0001-01" && /^\d{4}-(?:0[1-9]|1[0-2])$/.test(value)
? value
: bankToday().slice(0, ISO_MONTH_LENGTH);
}
function bankToday() {
return new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
timeZone: "Europe/Moscow",
year: "numeric",
}).format(new Date());
}
function moneyCents(value: string): bigint | null {
const normalized = value.trim().replace(",", ".");
if (!/^-?\d+(\.\d{1,2})?$/.test(normalized)) return null;
const [whole, fraction = ""] = normalized.replace("-", "").split(".");
if (!/^-?\d+$|^-?\d+\.\d{1,2}$/.test(normalized)) return /* eslint-disable-line unicorn/no-null -- The money parser’s callers distinguish invalid input with null. */ null;
const [whole, fraction = ""] = normalized.replace("-", "").split(".", DECIMAL_PART_COUNT);
return (
BigInt(`${whole}${fraction.padEnd(2, "0")}`) *
(normalized.startsWith("-") ? -1n : 1n)
BigInt(`${whole}${fraction.padEnd(CENTS_PRECISION, "0")}`)
* (normalized.startsWith("-") ? -1n : 1n)
);
}
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
@@ -42,9 +47,9 @@ const moneySchema = z.string().refine(
(value) => {
const cents = moneyCents(value);
return (
cents !== null &&
cents >= MIN_BALANCE_KOPECKS &&
cents <= MAX_BALANCE_KOPECKS
cents !== null
&& cents >= MIN_BALANCE_KOPECKS
&& cents <= MAX_BALANCE_KOPECKS
);
},
{ error: validationMessage("bank.validation.money") },
@@ -59,15 +64,25 @@ const positiveMoneySchema = moneySchema.refine(
const bankDateSchema = z
.string()
.refine(
(value) =>
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
value >= "0001-01-01" &&
!Number.isNaN(Date.parse(`${value}T00:00:00Z`)) &&
new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value &&
value <= bankToday(),
value =>
/^\d{4}-\d{2}-\d{2}$/.test(value)
&& value >= "0001-01-01"
&& !Number.isNaN(Date.parse(`${value}T00:00:00Z`))
&& new Date(`${value}T00:00:00Z`).toISOString().slice(0, ISO_DATE_LENGTH) === value
&& value <= bankToday(),
{ error: validationMessage("bank.validation.date") },
);
const bankCardSchema = z.object({
bankName: z
.string()
.trim()
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined),
),
lastFour: z.string().refine(value => !value || /^\d{4}$/.test(value), {
error: validationMessage("bank.validation.lastFour"),
}),
name: z
.string()
.trim()
@@ -76,29 +91,19 @@ const bankCardSchema = z.object({
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.name")(undefined),
),
bankName: z
.string()
.trim()
.max(
MAX_CARD_NAME_LENGTH,
validationMessage("bank.validation.bankName")(undefined),
),
lastFour: z.string().refine((value) => !value || /^\d{4}$/.test(value), {
error: validationMessage("bank.validation.lastFour"),
}),
openingBalance: moneySchema,
openingDate: bankDateSchema,
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
error: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
openingBalance: moneySchema,
openingDate: bankDateSchema,
});
type BankCardValues = z.infer<typeof bankCardSchema>;
export {
type BankCardValues,
bankCardSchema,
type BankCardValues,
bankDateInputValue,
bankDateSchema,
bankMoneyValue,
@@ -1,140 +1,137 @@
import type { BankOperation } from "../api/queries";
type OperationIcon = { path: string; color: string; animation?: string };
type OperationIcon = { animation?: string; color: string; path: string };
const categoryIcons: Record<string, OperationIcon> = {
алкоголь: {
"алкоголь": {
animation: "wine",
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
color: "grape",
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
},
вклады: {
"вклады": {
animation: "deposit",
path: "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
color: "green",
path: "M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
},
"жилье": { color: "orange", path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8" },
"здоровье": { color: "red", path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z" },
"инвестиции и накопления": {
animation: "growth",
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
color: "green",
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
},
образование: {
"кафе": {
animation: "coffee",
color: "orange",
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
},
"образование": {
color: "blue",
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
color: "blue",
},
переводы: {
"переводы": {
animation: "transfer",
path: "M3 7h18l-4-4 M21 17H3l4 4",
color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
},
"погашение кредита": {
animation: "repayment",
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
color: "orange",
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
},
подписки: {
path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
"подписки": {
color: "grape",
path: "M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
},
путешествия: {
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
"покупки": {
color: "grape",
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
},
"продукты": {
animation: "basket",
color: "green",
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
},
"путешествия": {
color: "blue",
path: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
},
"развлечения": {
color: "grape",
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
},
"связь и интернет": {
animation: "wifi",
path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
color: "blue",
path: "M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
},
"снятие наличных": {
color: "green",
path: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
color: "green",
},
ставки: {
path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
"ставки": {
color: "grape",
path: "M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
},
страхование: {
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
"страхование": {
color: "green",
path: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
},
табак: {
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
"табак": {
color: "orange",
path: "M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
},
"такси": {
color: "yellow",
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
},
"транспорт": {
animation: "car",
color: "blue",
path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
},
"хостинг и ии-сервисы": {
animation: "server",
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
color: "grape",
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
},
"цифровые покупки": {
color: "blue",
path: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
color: "blue",
},
продукты: {
animation: "basket",
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
color: "green",
},
кафе: {
animation: "coffee",
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
color: "orange",
},
такси: {
path: "M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
color: "yellow",
},
транспорт: {
animation: "car",
path: "M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
color: "blue",
},
жилье: { path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8", color: "orange" },
здоровье: { path: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z", color: "red" },
покупки: {
path: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
color: "grape",
},
развлечения: {
path: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
color: "grape",
},
};
const otherIcon: OperationIcon = {
animation: "grid",
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
color: "gray",
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
};
const operationIcons: Record<
Exclude<BankOperation["kind"], "expense">,
OperationIcon
> = {
refund: { color: "teal", path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14" },
topup: {
animation: "topup",
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
color: "green",
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
},
transfer: {
animation: "transfer",
path: "M3 7h18l-4-4 M21 17H3l4 4",
color: "blue",
path: "M3 7h18l-4-4 M21 17H3l4 4",
},
refund: { path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14", color: "teal" },
};
function bankOperationAppearance(
kind: BankOperation["kind"],
categoryName?: string | null,
categoryName?: null | string,
) {
if (kind !== "expense") return /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ operationIcons[kind];
const name = categoryName
?.trim()
.toLocaleLowerCase("ru-RU")
.replaceAll("ё", "е");
return kind === "expense"
? name && Object.hasOwn(categoryIcons, name)
? categoryIcons[name]
: otherIcon
: operationIcons[kind];
return name && Object.hasOwn(categoryIcons, name) ? /* eslint-disable-line security/detect-object-injection -- Operation kinds are finite; category names are guarded by Object.hasOwn. */ categoryIcons[name] : otherIcon;
}
export { bankOperationAppearance };
@@ -0,0 +1,10 @@
import type { BankOperation } from "../api/queries";
const bankOperationKinds = [
"expense",
"topup",
"transfer",
"refund",
] as const satisfies readonly BankOperation["kind"][];
export { bankOperationKinds };
@@ -0,0 +1,26 @@
import { afterEach, expect, it, vi } from "vitest";
import { bankPeriod } from "./period";
afterEach(() => vi.useRealTimers());
it.each([
["0001-01", "0001-01-01", "0001-01-31"],
["0099-12", "0099-12-01", "0099-12-31"],
["2026-09", "2026-09-01", "2026-09-30"],
["2024-02", "2024-02-01", "2024-02-29"],
["0004-02", "0004-02-01", "0004-02-29"],
])("keeps the ISO year for legacy month %s", (month, from, to) => {
expect(bankPeriod(new URLSearchParams({ month }))).toEqual({ from, to });
});
it("rejects zero year consistently in legacy and explicit periods", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-27T10:00:00Z"));
for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) {
expect(bankPeriod(new URLSearchParams(query))).toEqual({
from: "2026-09-01",
to: "2026-09-30",
});
}
});

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