Compare commits

...
Author SHA1 Message Date
ksv741 41f0ae7469 fix(deps): patch basic-ftp advisory (WealthLedger/main#56)
Frontend verify / commits (pull_request) Successful in 37s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 15s
Frontend verify / verify (push) Failing after 9m25s
2026-11-13 08:15:23 +03:00
codex-bot 2b94a16ae5 refactor!: support RUB only (WealthLedger/main#55) (#168)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 9m0s
2026-11-11 09:36:29 +03:00
codex-bot e2062ce837 feat(account): add performance tab vs Moscow Exchange index (WealthLedger/main#54) (#167)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 8m17s
2026-11-08 16:49:39 +03:00
codex-bot d910d624d6 test: remove timing races from screen-sync and route transition tests (#138) (#166)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 9m3s
2026-11-07 18:44:44 +03:00
codex-bot cc45352243 feat: clarify grouped coupon payouts (#162) (#165)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m26s
2026-11-07 11:18:07 +03:00
codex-bot ed0b5388ab fix: unify history and upcoming events labels (#161) (#163)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m18s
2026-11-07 10:50:51 +03:00
codex-bot 134c951178 fix: polish accounts portfolio summary (#159)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m26s
2026-11-06 15:17:27 +03:00
ksv741 2fd7d59e65 chore(release): 1.4.0
Frontend verify / commits (pull_request) Successful in 30s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m31s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 5m38s
Frontend image / publish (push) Successful in 7m13s
2026-11-06 07:45:29 +03:00
codex-bot 668f2ef70e fix: align audit findings for Events, currency and auth (WealthLedger/frontend#155)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m7s
2026-11-05 07:23:07 +03:00
codex-bot cadba8d075 refactor: share path-root query predicate (WealthLedger/frontend#142) (#154)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m42s
2026-11-04 22:58:58 +03:00
codex-bot 2db39f25d9 refactor: normalize view models and share breadcrumbs and titles (WealthLedger/frontend#143) (#153)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 8m19s
2026-11-04 21:14:29 +03:00
codex-bot 2d070ce2b5 refactor(api): consume bank operation oneOf contract (WealthLedger/frontend#144) (#152)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 8m20s
2026-11-04 20:47:51 +03:00
codex-bot 5077bd4f0b refactor: drop dead query helpers and simplify read deadline (WealthLedger/frontend#141) (#149)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m35s
2026-11-04 18:50:27 +03:00
codex-bot e2d143df46 refactor: move portfolio structure into account page, drop widgets (WealthLedger/frontend#139) (#140)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m51s
2026-11-04 18:01:00 +03:00
codex-bot ad93725a84 chore(deps): set Renovate release age, PR limit and update groups (WealthLedger/main#6) (#137)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m56s
2026-11-03 14:07:40 +03:00
codex-bot 8ce1e8e444 feat(investments): warn and offer retry after exhausted read retries (WealthLedger/frontend#55) (#136)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m5s
2026-11-03 09:49:08 +03:00
codex-bot aaee9591d7 chore(api): update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend#134) (#135)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m24s
2026-11-03 07:21:35 +03:00
codex-bot 9619dc9791 fix(account): show source metadata on empty account and income (WealthLedger/frontend#55) (#133)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m34s
2026-11-03 06:44:13 +03:00
codex-bot 71fbe7774d refactor: replace repeated lazy page wrappers with lazyPage helper (#131) (#132)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 8s
Frontend verify / verify (push) Failing after 6m34s
2026-11-02 07:31:25 +03:00
ksv741 870b7e6327 chore(release): 1.3.0
Frontend verify / commits (pull_request) Successful in 32s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m56s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m22s
Frontend image / publish (push) Successful in 6m22s
2026-11-01 19:33:21 +03:00
codex-bot 70a2c335fd feat(account): add concentration tab with top-5 slices and chart (WealthLedger/main#48) (#127)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 9m31s
2026-11-01 19:28:52 +03:00
codex-bot 7242240b21 chore(deps): resolve minimatch and glob audit advisories (WealthLedger/main#49) (#128)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m6s
2026-11-01 19:13:02 +03:00
codex-bot 71126c91f0 feat: group all-account rows by instrument and day (WealthLedger/main#47) (#126)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m1s
2026-10-30 07:28:19 +03:00
codex-bot 6792f5b70a fix: show history loading alert only while loading (WealthLedger/frontend#124) (#125)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 6m57s
2026-10-28 21:00:45 +03:00
codex-bot 32e8fe9402 refactor: derive api error codes from contract (WealthLedger/backend#117) (#123)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 6m30s
2026-10-28 17:31:22 +03:00
codex-bot 5796d2d789 fix: stabilize auth client registration (WealthLedger/frontend#95) (#122)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 6m58s
2026-10-28 16:59:09 +03:00
codex-bot a56edf2b37 fix: show account source sync status (WealthLedger/frontend#120) (#121)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 7m0s
2026-10-28 16:34:19 +03:00
codex-bot 1d4990491f fix: round money to currency minor units without negative zero (WealthLedger/frontend#118) (#119)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m26s
2026-10-28 08:45:55 +03:00
ksv741 431559be0f feat: show return, cash and structure on account cards (#114)
Frontend verify / commits (pull_request) Successful in 29s
Frontend verify / security (pull_request) Successful in 8s
Frontend verify / verify (pull_request) Successful in 5m25s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m20s
2026-10-27 05:29:11 +03:00
ksv741 0e4bbec2a7 chore: sync brokerage accounts contract (WealthLedger/backend#138) 2026-10-27 05:29:11 +03:00
ksv741 c24704affa feat: make brokerage account list compact (#111) 2026-10-27 05:29:11 +03:00
codex-bot 58b4e12633 test: pin date in all accounts events test (#115)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Failing after 5m7s
2026-10-27 05:27:14 +03:00
codex-bot 3075081df1 feat: replace account page accordion with tabs and show open position result (#112)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 20s
Frontend verify / verify (push) Failing after 6m36s
2026-10-26 20:30:35 +03:00
codex-bot 1a92f023c1 feat: remove top header and rework mobile bottom navigation (#109)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Failing after 7m7s
2026-10-25 18:25:10 +03:00
ksv741 df86b8450f chore(release): 1.2.1
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m15s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 7m48s
Frontend image / publish (push) Successful in 9m17s
2026-10-24 19:15:04 +03:00
ksv741 b5ceb77208 chore(deps): update non-major dependencies #9
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m37s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 6m29s
2026-10-24 18:20:20 +03:00
codex-bot b009101b7b fix: show snapshot time in browser time zone (#101)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 6m33s
2026-10-24 11:04:50 +03:00
codex-bot 7e6fd95cfa chore: bump test stack and fix npm audit findings (#105)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 5m38s
2026-10-24 10:32:11 +03:00
codex-bot 2cd6b1e72d chore: bump Node to 24.22.1 (WealthLedger/backend#122)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Failing after 7m25s
2026-10-23 16:10:45 +03:00
codex-bot 3af9df0ae2 fix: keep session when refresh fails on network error or 5xx (WealthLedger/frontend#97)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 1m3s
Frontend verify / verify (push) Failing after 7m11s
2026-10-23 15:06:20 +03:00
codex-bot 7b6f34c776 chore: resolve dev dependency audit findings (WealthLedger/frontend#103)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m33s
2026-10-23 14:13:08 +03:00
ksv741 ed811e88fa chore(release): 1.2.0
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m48s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
Frontend image / publish (push) Successful in 7m55s
2026-10-18 16:24:32 +03:00
codex-bot eefdde29f8 chore: adopt linear git model in frontend docs (#91)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 39s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Failing after 7m24s
2026-10-17 14:25:51 +03:00
codex-bot 5134106696 test: cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend#59) (#90)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 44s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m45s
2026-10-16 23:23:12 +03:00
codex-bot ebe92684a7 fix: align password validation with backend ASCII policy (WealthLedger/backend#60) (#89)
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 40s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Failing after 7m35s
2026-10-16 10:00:54 +03:00
codex-bot 6f1226b668 Merge pull request 'chore: synchronize release 1.1.0 into develop (WealthLedger/main#30)' (#68) from main into develop
Frontend verify / commits (push) Skipped
Frontend verify / commits (pull_request) Failing after 35s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m57s
2026-10-16 08:04:34 +03:00
codex-bot d409a297e5 ci: keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend#62) (#86)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m21s
Frontend verify / commits (pull_request) Failing after 34s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-15 06:34:36 +03:00
codex-bot ed63adad94 fix: deliver screener OpenAPI constraints (WealthLedger/backend#70) (#85)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 9s
Frontend verify / verify (push) Successful in 6m25s
2026-10-15 06:15:53 +03:00
codex-bot bc28973d1a ci: recheck PR title on edited without the full pipeline (WealthLedger/frontend#64)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m12s
2026-10-14 22:46:51 +03:00
codex-bot 20618fb033 Merge pull request 'fix(events): seven-date default period and picker from today (WealthLedger/frontend#63)' (#83) from fix/64-upcoming-events-date-filter into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 8m18s
2026-10-14 21:34:59 +03:00
ksv741 612ab62509 fix(events): seven-date default period and picker from today (WealthLedger/frontend#63)
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 7m7s
2026-10-14 21:22:09 +03:00
codex-bot f295dea6a6 fix: respect query eligibility in screen sync (WealthLedger/frontend#54)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 13s
Frontend verify / verify (push) Successful in 8m22s
2026-10-14 17:01:53 +03:00
codex-bot f3725c202b test: await client settlement in late-response cases (WealthLedger/frontend#61) (#81)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m35s
2026-10-14 14:13:41 +03:00
ksv741 edc7097bd7 Merge pull request 'fix(auth,bank): guard switch-auth nav and surface banking retry (#56, #57)' (#80) from feature/56-57-auth-pending-banking-feedback into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m50s
Reviewed-on: #80
2026-10-14 12:01:28 +03:00
ksv741 233680448f fix(bank): break BankFeedback's shared/api edge for stories (#80)
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m36s
BankFeedback lived under entities/bank-card/ui but pulled apiErrorCode
from @/shared/api, which dragged the entire api layer (openapi-fetch,
openapi-react-query, @tanstack/react-query, client, problem-details,
resource-query) into any Storybook story that imported a sibling
view, breaking stories-must-not-reach-application-data.

Move the local 'extract code from unknown' helper into the entity
and consume it from BankFeedback. The shared helper stays in
shared/api for the rest of the app.
2026-10-14 11:25:20 +03:00
ksv741 e38ecd87d6 fix(auth,bank): guard switch-auth nav and surface banking retry
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 5m50s
AuthForm disables the switch-auth Link while submit is pending so pointer
click and Enter can no longer leave the auth route and lose the draft/error
context. The form restores a true Link after the request settles.

CardsPage/CardPage distinguish cold vs cached banking failures:
- cold card/section errors expose accessible alert + retry
- cached failures keep the previous data and offer a section GET retry
- summary-only/history-only outages do not hide successful sections
- notFound still skips the retry CTA

Tests cover the pending link guard, cold/cached cards error with retry,
summary failure with retry, and the prerequisite card list recovery.

Closes #56, #57
2026-10-14 10:43:34 +03:00
codex-bot e5f8bbe33c test: cover null events and income envelopes on account pages (#72) (#79)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m11s
2026-10-13 23:15:56 +03:00
codex-bot f7f53ff8e4 test: cover null resource envelopes for account pages (#70) (#71) (#78)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m21s
2026-10-13 22:53:28 +03:00
codex-bot df05c8c7db build: use native openapi-typescript --check (#60) (#77)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m33s
2026-10-13 21:32:33 +03:00
codex-bot e67c47b102 refactor: derive payment-system options from schema tuple (#58) (#75)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m56s
2026-10-13 21:05:07 +03:00
codex-bot 43b0f5574a feat: unify income payment cards and remove comparisons (WealthLedger/main#33) (#74)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m18s
feat: unify income payment cards and remove comparisons (WealthLedger/main#33)
2026-10-13 18:17:35 +03:00
ksv741 c7d8d2c8f9 ci: validate release PR commits independently (WealthLedger/main#30)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend image / publish (push) Successful in 6m45s
Frontend verify / verify (push) Successful in 6m7s
Frontend verify / commits (pull_request) Successful in 37s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m58s
2026-10-12 23:39:30 +03:00
ksv741 9d900b331a chore(release): 1.1.0
Frontend verify / commits (pull_request) Failing after 42s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
codex-bot b357743c10 Merge pull request 'chore: align frontend engineering workflow (WealthLedger/main#27)' (#66) from chore/27-engineering-workflow into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 7m9s
2026-10-12 11:00:35 +03:00
ksv741 0d747cc93e docs: pin frontend workflow reference (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m7s
2026-10-12 10:22:13 +03:00
ksv741 c315f80b1b fix: align frontend workflow rules (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Canceled after 1m36s
2026-10-12 10:19:36 +03:00
ksv741 617edfd489 chore: align frontend engineering workflow (WealthLedger/main#27)
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Failing after 3m11s
2026-10-12 09:20:13 +03:00
codex-bot 9ad3245efe Merge pull request 'docs: clarify frontend verification policy' (#65) from codex/issue-24-process into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m56s
2026-10-11 23:23:10 +03:00
ksv741 cac4351bc4 docs: clarify frontend verification policy
Frontend verify / commits (pull_request) Successful in 41s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 6m34s
2026-10-11 23:06:51 +03:00
codex-bot 17f5bae69b feat: add stock screener
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 6m39s
2026-10-11 11:50:39 +03:00
ksv741 c8821200ce ci: skip checks on PR metadata edits
Frontend verify / commits (pull_request) Successful in 34s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m37s
2026-10-11 11:36:51 +03:00
ksv741 cec9c6348a fix: preserve latest stock screener result
Frontend verify / commits (pull_request) Successful in 35s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m47s
2026-10-11 11:11:58 +03:00
ksv741 a84411df39 feat: add stock screener 2026-10-11 11:11:58 +03:00
ksv741 3c226f8c29 feat: render hourly investment states
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 12s
Frontend verify / verify (push) Successful in 7m41s
2026-10-09 21:13:34 +03:00
ksv741 20b2cdfd43 fix: show bank summary totals 2026-10-09 21:13:34 +03:00
codex-bot dcd1e3c278 Merge pull request 'feat: align investment client with hourly sync' (#50) from release/1.0.0 into develop
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m32s
Merge hourly sync frontend client
2026-10-09 09:19:49 +03:00
ksv741 432cca299f feat: align investment client with hourly sync
Frontend verify / commits (pull_request) Successful in 40s
Frontend verify / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 6m43s
2026-10-09 09:04:45 +03:00
ksv741 54deae57b3 chore(release): 1.0.0
Frontend image / publish (push) Successful in 7m39s
Frontend verify / commits (pull_request) Successful in 39s
Frontend verify / security (pull_request) Successful in 12s
Frontend verify / verify (pull_request) Successful in 6m39s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 7m34s
2026-10-07 18:14:21 +03:00
ksv741 74f8a72ed5 perf(tbank-connection): poll screen sync every five seconds
Frontend verify / commits (pull_request) Successful in 48s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 6m57s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 11s
Frontend verify / verify (push) Successful in 8m21s
During active screen sync, status checks and observe reads run every 5s
instead of 1s; terminal state observes resources immediately.
OpenSpec change: WealthLedger/specs#21
2026-10-07 14:03:55 +03:00
ksv741 fcb938b5a2 chore: remove unused exports
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 11s
Frontend verify / verify (pull_request) Successful in 7m16s
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 14s
Frontend verify / verify (push) Failing after 6m28s
2026-10-07 09:10:03 +03:00
ksv741 f873db7b02 fix(deps): resolve npm audit advisories
Frontend verify / commits (pull_request) Successful in 36s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 32m34s
2026-10-07 07:27:16 +03:00
ksv741 0ea8d7b62d feat: default journal period to last 7 days
Frontend verify / commits (pull_request) Successful in 50s
Frontend verify / security (pull_request) Failing after 13s
Frontend verify / verify (pull_request) Skipped
2026-10-06 22:05:41 +03:00
ksv741 c25f6bd85b refactor: move PortfolioStructure to widgets layer 2026-10-06 22:05:24 +03:00
ksv741 5b30bb802a feat: add quiet reading with rare confirmations
Part of the quiet-user-feedback change; docs and specs land in their own repositories.
2026-10-06 07:01:58 +03:00
ksv741 c096fc70ef fix(deps): remove npm dependency on Nexus
Frontend verify / commits (pull_request) Successful in 42s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Failing after 36m0s
Nexus hosts release Docker images, not npm dependencies.

Omit registry URLs using npm's native lockfile option.

Versions and integrity hashes remain unchanged.
2026-10-03 22:09:33 +03:00
ksv741 69997075e5 refactor!: simplify query and session ownership
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
2026-10-03 11:45:04 +03:00
258 changed files with 14735 additions and 9918 deletions
+4 -3
View File
@@ -34,9 +34,10 @@ contract; frontend не читает его checkout для генерации
| Представление/поведение бизнес-сущности | `entities/<entity>` |
| Переиспользуемый код без WealthLedger domain meaning | `shared` |
Локальный FSD profile: `app → pages → features → entities → shared`, без `widgets`.
Крупный уникальный блок остаётся в своей page. Form, mutation или click handler
сами по себе не требуют feature. Два callers не делают domain helper generic shared.
Локальный FSD profile: `app → pages → features → entities → shared`.
Слой `widgets` не используется: композиция экрана и компонент для одной page
остаются у page. Form, mutation или click handler сами по себе не
требуют feature. Два callers не делают domain helper generic shared.
Slice именуется по ответственности, а не `helpers`, `hooks` или `types`.
**Безопасный путь:** при сомнении оставь код у ближайшего допустимого owner.
+20 -1
View File
@@ -30,7 +30,8 @@ backend checkout и не проверяют backend runtime OpenAPI; измен
shape/status/route сохраняет
публичный контракт предоставленного snapshot.
Одна UI-модель не требует менять API. `openapi:generate` работает offline от snapshot;
`openapi:check` генерирует во временный каталог и сравнивает, не меняя tracked files.
`openapi:check` запускает native `--check` того же CLI и падает при устаревших
generated types, не меняя tracked files.
## Auth и invalidation
@@ -39,6 +40,15 @@ Authorization. Auth endpoints исключены из Bearer/retry middleware. R
координируется single-flight; после `401` с `UNAUTHORIZED` возможен один retry.
Не добавляй собственный refresh/retry loop в caller.
Refresh, завершившийся `401`, очищает session. Сетевая ошибка и `5xx`
(например, деплой backend) не доказывают конец session: она сохраняется,
`unavailable` показывает retry-экран вместо login, а следующий `401` снова
запускает refresh. Retry/backoff внутри refresh не добавляется.
Cookie-writing requests упорядочены только в текущей вкладке. Cross-tab auth
synchronization не требуется; допустим reload/re-login. Web Locks и отдельные
token stores не добавляются.
Protected queries используют existing session-aware boundary; logout очищает
session-scoped cache. Access token хранится только в памяти, refresh передаётся
httpOnly cookie с `credentials: include`. Не сохраняй tokens в localStorage,
@@ -50,6 +60,15 @@ GET subtree; ошибка mutation не меняет generation/cache. Это о
от обычного фонового refetch. Даже повторное сохранение того же token начинает
новую generation; поздний ответ прежней generation не попадает в новый кэш.
Отмена/reset принадлежат TanStack Query и generated queryFn с AbortSignal,
без HTTP request registry, URL-wide latest-read counters и access epochs.
Успешный HTTP с `data: null` остаётся query success; readiness выбирается по
data/metadata, без подмены query status и отдельного transport state.
Status синхронизации содержит только состояние работы. Resources читаются
через `sync=observe` во время работы и после завершения, без hash comparison.
Согласованность income entries/groups обеспечивает backend; frontend не
дублирует её runtime validator.
Mutations используют существующий generated client и query-key owner;
после успеха обновляй/invalidируй только связанные данные. Optimistic update
нужен конкретному UX и обязан иметь rollback/error behavior.
+35 -15
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
OpenSpec lifecycle начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из frontend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope
@@ -57,21 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
чтения и `git diff --check`; application build ради текста не нужен.
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
и ограничения. Зелёный lint не заменяет semantic/security review.
| Когда | Что проверить |
| --- | --- |
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
| Pre-commit | Hook не скачивает tools: вызывай установленные бинарники из `node_modules/.bin` (не `npx`). Full graph не входит в hook; он выполняется в `npm run verify` и CI. |
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
содержит одну связанную задачу. При связанной issue добавь её номер в конец
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout
не управляет этим repo.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
содержит одну связанную задачу. Исключения release и правила ссылки — в
Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
используется. `main` обновляется только fast-forward из `develop`, поэтому
`origin/main` всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Перед релизом прочитай местный README, раздел «Релизы».
+3
View File
@@ -13,6 +13,9 @@
!public/**
!docker/
!docker/**
!scripts/
!scripts/minimatch-compat/
!scripts/minimatch-compat/**
!packages/
!packages/tokens/
!packages/tokens/package.json
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+34
View File
@@ -0,0 +1,34 @@
name: Frontend verify
# Metadata-only: refreshes only the `commits` context after a PR title edit.
# Same workflow name keeps the required context; `security` and `verify`
# statuses of the head revision stay untouched (no skipped overwrite).
on:
pull_request:
types: [edited]
concurrency:
group: frontend-pr-title-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
commits:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
+7 -3
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened, edited]
types: [opened, synchronize, reopened]
schedule:
- cron: '17 5 * * *'
@@ -34,7 +34,11 @@ jobs:
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"
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security:
needs: commits
@@ -62,7 +66,7 @@ jobs:
- 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
run: npm audit --include=dev --audit-level=moderate
verify:
needs: [commits, security]
+5 -2
View File
@@ -1,3 +1,6 @@
gitleaks git --pre-commit --staged --redact=100 --no-banner || exit $?
npx lint-staged
npm run deps
if [ ! -x node_modules/.bin/lint-staged ]; then
echo "lint-staged is not installed; run npm ci" >&2
exit 1
fi
node_modules/.bin/lint-staged
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.1
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать |
| --- | --- |
+203
View File
@@ -1,5 +1,208 @@
# Changelog
## [1.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.3.0...v1.4.0) (2026-11-06)
### Features
* **investments:** warn and offer retry after exhausted read retries (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#136](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/136)) ([8ce1e8e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/8ce1e8e44486dcc23d4c074559ca2bb517880b60))
### Bug Fixes
* **account:** show source metadata on empty account and income (WealthLedger/frontend[#55](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/55)) ([#133](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/133)) ([9619dc9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/9619dc979172dbc16391206e801ed67862d20116))
* align audit findings for Events, currency and auth (WealthLedger/frontend[#155](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/155)) ([668f2ef](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/668f2ef70e535a1222e7f600b1be4a31fefc6f8b))
### Refactoring
* **api:** consume bank operation oneOf contract (WealthLedger/frontend[#144](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/144)) ([#152](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/152)) ([2d070ce](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2d070ce2b54bbee466ecf6cc18b437910f0c07f8))
* drop dead query helpers and simplify read deadline (WealthLedger/frontend[#141](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/141)) ([#149](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/149)) ([5077bd4](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5077bd4f0baa4a59643e875485c2f08253cc198d))
* move portfolio structure into account page, drop widgets (WealthLedger/frontend[#139](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/139)) ([#140](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/140)) ([e2d143d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e2d143df46e650139e49b7c80a886008ede86d2a))
* normalize view models and share breadcrumbs and titles (WealthLedger/frontend[#143](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/143)) ([#153](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/153)) ([2db39f2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2db39f25d9478350ad56a1b2e2ca6496068257c5))
* replace repeated lazy page wrappers with lazyPage helper ([#131](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/131)) ([#132](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/132)) ([71fbe77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71fbe7774d3ed63fcb84bc644da997921bc049b2))
* share path-root query predicate (WealthLedger/frontend[#142](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/142)) ([#154](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/154)) ([cadba8d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cadba8d075daf9b20c0c9de97ad5bd8f5a3e74fc))
### Maintenance
* **api:** update OpenAPI snapshot with UpcomingEventsDto.window (WealthLedger/frontend[#134](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/134)) ([#135](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/135)) ([aaee959](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/aaee9591d7880193ee9b0c14339e8477264e4a94))
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/6)) ([#137](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/137)) ([ad93725](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ad93725a84615ec329631b71ed8db33337085740))
## [1.3.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.1...v1.3.0) (2026-11-01)
### Features
* **account:** add concentration tab with top-5 slices and chart (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/48)) ([#127](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/127)) ([70a2c33](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/70a2c335fdddb8b6a99e3f609befe06aeb8cc45d))
* group all-account rows by instrument and day (WealthLedger/main[#47](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/47)) ([#126](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/126)) ([71126c9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/71126c91f0b30f4d65a313ddc7d7f86963340455))
* make brokerage account list compact ([#111](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/111)) ([c24704a](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c24704affacbe7fbc08f5e2b61bb6e7ffa6053b9))
* remove top header and rework mobile bottom navigation ([#109](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/109)) ([1a92f02](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1a92f023c1c79f646424f72710ab74de63386a11))
* replace account page accordion with tabs and show open position result ([#112](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/112)) ([3075081](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3075081df188de0630d314ba15a34fe6efd1eea9))
* show return, cash and structure on account cards ([#114](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/114)) ([431559b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/431559be0fca9eb37f6a09d930d502f939884e39))
### Bug Fixes
* round money to currency minor units without negative zero (WealthLedger/frontend[#118](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/118)) ([#119](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/119)) ([1d49904](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/1d4990491f254e1ec6bafad8204872eaae2982d0))
* show account source sync status (WealthLedger/frontend[#120](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/120)) ([#121](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/121)) ([a56edf2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a56edf2b3757ef0b0f6c42ecbd422a1a563f6505))
* show history loading alert only while loading (WealthLedger/frontend[#124](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/124)) ([#125](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/125)) ([6792f5b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6792f5b70aa3ba90b2eb2df7e63d3ae2f669e295))
* stabilize auth client registration (WealthLedger/frontend[#95](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/95)) ([#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([5796d2d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5796d2d789963e66a3a4bcd79eab44700f16bd31))
### Refactoring
* derive api error codes from contract (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/117)) ([#123](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/123)) ([32e8fe9](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32e8fe9402665ea0c350f5f247d87e328ffd44c8))
### Tests
* pin date in all accounts events test ([#115](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/115)) ([58b4e12](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/58b4e126330d01c1d6865bd0e9dd066a028fba08))
### Maintenance
* **deps:** resolve minimatch and glob audit advisories (WealthLedger/main[#49](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/49)) ([#128](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/128)) ([7242240](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7242240b2188e9d1ca1325e65d0e1a0a3655d882))
* sync brokerage accounts contract (WealthLedger/backend[#138](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/138)) ([0e4bbec](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0e4bbec2a7b814579095ea7ef6db68a70508fa29))
## [1.2.1](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.2.0...v1.2.1) (2026-10-24)
### Bug Fixes
* keep session when refresh fails on network error or 5xx (WealthLedger/frontend[#97](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/97)) ([3af9df0](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3af9df0ae2f0fb314c276504ef7d50a7472de680))
* show snapshot time in browser time zone ([#101](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/101)) ([b009101](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b009101b7b954e8462672a9da859267ebb71edc4))
### Maintenance
* bump Node to 24.22.1 (WealthLedger/backend[#122](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/122)) ([2cd6b1e](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2cd6b1e72d97117b0c37465aac3d989b0469c1c3))
* bump test stack and fix npm audit findings ([#105](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/105)) ([7e6fd95](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e6fd95cfaf7d288242fb46ec7091f561e6f3de5))
* **deps:** update non-major dependencies [#9](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/9) ([b5ceb77](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b5ceb77208e35a0e389258f3d3c4b85852980042))
* resolve dev dependency audit findings (WealthLedger/frontend[#103](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/103)) ([7b6f34c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7b6f34c776fa3517c93b5c3468e3b277aaa6273f))
## [1.2.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.1.0...v1.2.0) (2026-10-18)
### Features
* unify income payment cards and remove comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/33)) ([#74](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/74)) ([43b0f55](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/43b0f5574a79e26de0abae35de16d8aa8700f718))
### Bug Fixes
* align password validation with backend ASCII policy (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#89](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/89)) ([ebe9268](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ebe92684a74b4e2617b04ef00d9406b8a0f674ea))
* **auth,bank:** guard switch-auth nav and surface banking retry ([e38ecd8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e38ecd87d6f69968bb56e6035ceeb6cfda208a3f)), closes [#56](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/56) [#57](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/57)
* **bank:** break BankFeedback's shared/api edge for stories ([#80](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/80)) ([2336804](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/233680448f97dc3ec57d7665ec14c68ac92410e5))
* deliver screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#85](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/85)) ([ed63ada](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ed63adad94f5d0e20f7d0d1446a235f953d10ec2))
* **events:** seven-date default period and picker from today (WealthLedger/frontend[#63](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/63)) ([612ab62](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/612ab6250979bf8d9b57f4feb6e1476ed3d759e4))
* respect query eligibility in screen sync (WealthLedger/frontend[#54](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/54)) ([f295dea](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f295dea6a6a33c597c753b52aff629d5382c963a))
### Refactoring
* derive payment-system options from schema tuple ([#58](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/58)) ([#75](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/75)) ([e67c47b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e67c47b102329877eb5d9c9d8bb4ea7c0bc70883))
### Build
* use native openapi-typescript --check ([#60](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/60)) ([#77](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/77)) ([df05c8c](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/df05c8c7dbb5d6816fb95d54ca43cd0d83eecd48))
### CI
* keep pre-commit proportionate and avoid tool downloads (WealthLedger/frontend[#62](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/62)) ([#86](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/86)) ([d409a29](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/d409a297e5f9ae5658d4efa958bda9a012a859a1))
* recheck PR title on edited without the full pipeline (WealthLedger/frontend[#64](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/64)) ([bc28973](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/bc28973d1ad5e15bfbc2e5b73b00ba63449933db))
### Tests
* await client settlement in late-response cases (WealthLedger/frontend[#61](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/61)) ([#81](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/81)) ([f3725c2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f3725c202bd8cef4ba711085df82a921a2da6b7d))
* cover non-retry of INVALID_TBANK_TOKEN reads (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/59)) ([#90](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/90)) ([5134106](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/513410669680480a1f68f5720744cc85e43c8286))
* cover null events and income envelopes on account pages ([#72](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/72)) ([#79](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/79)) ([e5f8bbe](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/e5f8bbe33cd438f432281818c5276abe9a425646))
* cover null resource envelopes for account pages ([#70](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/70)) ([#71](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/71)) ([#78](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/78)) ([f7f53ff](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f7f53ff8e4de3c1d06fac6063974ff9667145ed6))
### Maintenance
* adopt linear git model in frontend docs ([#91](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/91)) ([eefdde2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/eefdde29f8da269581e90bd797078e10a39e472f))
## [1.1.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v1.0.0...v1.1.0) (2026-10-12)
### Features
* add stock screener ([a84411d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a84411df39dc131290e9f5b5d9313fed17cc629a))
* align investment client with hourly sync ([432cca2](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/432cca299f3a9a9e45b2697527369ee95f2d0adc))
* render hourly investment states ([3c226f8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/3c226f8c29efceccb75af0d6424639a757534f39))
### Bug Fixes
* align frontend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([c315f80](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c315f80b1b07ae0888366512ec157b8e29c81d62))
* preserve latest stock screener result ([cec9c63](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cec9c6348a389f16f272dac5533aebc9d12ceae8))
* show bank summary totals ([20b2cdf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/20b2cdfd432f8b01e447f68657cf5c685a297f1e))
### Documentation
* clarify frontend verification policy ([cac4351](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/cac4351bc4abf2cec7c5b6611a74d87bf4ec2f70))
* pin frontend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([0d747cc](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0d747cc93ee08c337cf300c0367612708f45dfb9))
### CI
* skip checks on PR metadata edits ([c882120](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c8821200ce699dab1ce65360d1968d5877a2e78b))
### Maintenance
* align frontend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/27)) ([617edfd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/617edfd489a6a84bb33b57386eca4eb54661f4f0))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
### Features
* add quiet reading with rare confirmations ([5b30bb8](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b30bb802a4ec43c036066f9a7e178f37e747a0b))
* default journal period to last 7 days ([0ea8d7b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0ea8d7b62d50a4b7c92e9a9934aead6246ac97f9))
### Bug Fixes
* **deps:** remove npm dependency on Nexus ([c096fc7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c096fc70ef442474c9b7766fdf5772b645166b92))
* **deps:** resolve npm audit advisories ([f873db7](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f873db7b027b6dee0c7382b4bd3df5198c7a4198))
* **deps:** resolve npm audit advisories ([10baefd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/10baefda790e7fb491c893724ddc4da7992103f0))
* resolve frontend audit findings ([f6db28d](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f6db28defa51bd9d8fe932ceae4c3c720aba7eb3))
* sort stock and bond events by payment date ([f5f4447](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f5f4447d60661aa92c80b50a16e8cb194dfa161f))
### Performance
* **tbank-connection:** poll screen sync every five seconds ([74f8a72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/74f8a72ed55af2348fa3668f57312f4fdfc1e8a8)), references [WealthLedger/specs#21](https://git.personal.ksv741.ru/WealthLedger/specs/issues/21)
### Refactoring
* address frontend review feedback ([a4046db](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a4046dbe87bd7dc949df32da3b6aaf174e6f5e8f))
* clarify frontend model ownership ([0865b35](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0865b35f93865b4052181d918cee80c995312930))
* clarify frontend module ownership ([f9f0f69](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/f9f0f6921e86f6c81510373c680b136e4580f386))
* encapsulate instrument chart data ([64fe902](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/64fe902192b60a72440635f5610ebe02cf8543f8))
* enforce enum machine codes [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5b69003](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/5b6900351612b6ba34c694fc99612733e7901c79))
* enforce frontend view model boundaries ([ca0ad4f](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ca0ad4f824a47fa47e32d4717e27b15c8a985f7d))
* move PortfolioStructure to widgets layer ([c25f6bd](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c25f6bd85b88c4c7acb2f5ae6fc013aec77005b2))
* remove redundant page view models ([fb3a5a6](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fb3a5a6eaa0223a15ea2c9f585a06459371f0a25))
* share instrument search presentation ([32a7786](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/32a77860c8eaea4a59a26e55b2d66976fd8f9484))
* share source sync status presentation ([0bc8bc3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/0bc8bc36b7263b7f0120a08df840b290b40d8116))
* simplify auth and instrument pages ([b404c18](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/b404c1802a74f5f369173fdfcb086a3d5760e35a))
* simplify calendar and form state ([94adbaf](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/94adbafa344b23d7f95c31db975d99d990bfcb8b))
* simplify errors and focus tests ([7e976c3](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7e976c330c54715018eda4bff0764c5d4de9c8b0))
* simplify finance view models ([2844b22](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/2844b22dbace41a02c1b5a07b3801e84c66b3528))
* simplify frontend state and mutation boundaries ([a7537da](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/a7537da34452ab1a15138ab0c1cd93d53d06c517))
* simplify query and session ownership ([6999707](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/69997075e569da3edd43e242ef36f09311262412))
* simplify resource queries and layout tests ([c1b0873](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/c1b08735e3a45d565c3836ae37ad76186bf48f26))
* tighten types and reuse instrument UI ([7c28a42](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/7c28a423dd8a3cc4a5bdcb49b8de7018979df413))
* unify bank form types and actions ([ff67a76](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/ff67a768addc0c4ba55247f5e2d9d5d5d5aec734))
### CI
* reduce duplicate actions and serialize checks ([95fdc87](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/95fdc8759e20f9baa4ac3d00e703752c79010e01))
### Tests
* remove redundant frontend checks ([320ea59](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/320ea5922a70ab81ce4f05c3c90eb7b6a664fa39))
### Styles
* pin ternary operator formatting [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([12c3b72](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/12c3b72bfb32dff5f1ffdb36519c90f7ea852488))
### Maintenance
* add typed ESLint checks [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([5763010](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/57630102e4bcc6b555dd546ba373a14a88256eed))
* finish frontend ESLint migration [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([72d7835](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/72d7835227340e349d99f57db134a635f276f6e9))
* guard presentation data boundaries ([6a89b08](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/6a89b08ee571562fea349801169c40f1f993e9b9))
* guard unsafe DOM HTML with ESLint [#18](https://git.personal.ksv741.ru/WealthLedger/frontend/issues/18) ([061ec70](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/061ec708c651029cd7d61d22319d11e7ac9f074e))
* remove unused exports ([fcb938b](https://git.personal.ksv741.ru/WealthLedger/frontend/commit/fcb938b5a29fc82f00bbaa8b2ee776ca57dad010))
## [0.4.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.3.0...v0.4.0) (2026-09-26)
### Features
+3 -2
View File
@@ -1,10 +1,11 @@
FROM node:24.21.0-bookworm-slim AS dependencies
FROM node:24.22.1-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/
COPY scripts/minimatch-compat ./scripts/minimatch-compat
RUN npm ci
FROM dependencies AS development
@@ -19,7 +20,7 @@ COPY . .
ENV VITE_API_BASE_URL=""
RUN npm run build
FROM nginx:1.28.0-alpine AS production
FROM nginx:1.31.8-alpine AS production
ARG VERSION
ARG REVISION
LABEL org.opencontainers.image.version=$VERSION \
+54 -23
View File
@@ -19,8 +19,10 @@ npm run dev
Для commits установите [Gitleaks](https://github.com/gitleaks/gitleaks/releases/tag/v8.30.1)
8.30.1 в `PATH` (macOS: `brew install gitleaks`). Pre-commit сначала проверяет
staged diff с полной редакцией секретов, затем запускает formatting и FSD checks.
Находка или отсутствие Gitleaks блокирует commit.
staged diff с полной редакцией секретов, затем запускает установленный
`node_modules/.bin/lint-staged` только для staged JS/TS. Hook не скачивает tools и
не запускает full graph: полный `npm run verify` выполняется в CI и перед передачей.
Находка или отсутствие Gitleaks либо lint-staged блокирует commit.
Проверка этого поведения: `node --test scripts/test-pre-commit.mjs`.
## Контейнерный image
@@ -116,14 +118,17 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
Изменение заголовка PR запускает `.gitea/workflows/pr-title.yml` (то же имя workflow,
только job `commits`): статусы `security` и `verify` на HEAD не перезаписываются.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`.
Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет
`npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job;
исключений frontend нет. Локально можно выполнить те же команды:
`npm audit --include=dev --audit-level=moderate`. Advisory уровня moderate и выше
блокирует security job; low не блокирует, т.к. для них часто нет исправленной версии
(например, GHSA-gcqx-w48m-w2h4 в build-only deepmerge). Других исключений нет.
Локально можно выполнить те же команды:
`gitleaks git --log-opts=--all --redact=100 --no-banner` и
`npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry;
`npm audit --include=dev --audit-level=moderate`. Audit требует доступа к npm registry;
проверка секретов выполняется локально и не выводит их значения.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
@@ -203,7 +208,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу.
Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,36 +217,61 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
`release/hotfix-<name>` и проходит тот же процесс.
```text
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
```
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
изменился, согласуй историю и повтори проверки кандидата.
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
существующий кандидат без повторного повышения версии.
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
без нового bump; до merge исправь и проверь существующий кандидат без
повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+6
View File
@@ -1,6 +1,12 @@
module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"],
ignores: [
message => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
message => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: {
"references-empty": [2, "never"],
"type-enum": [
2,
"always",
+9
View File
@@ -17,12 +17,21 @@ http {
default $http_x_forwarded_proto;
'' $scheme;
}
# One shared budget: the external proxy's client-IP forwarding is not verified.
map "$request_method:$uri" $auth_admission_key {
default "";
POST:/api/v1/auth/login auth;
POST:/api/v1/auth/register auth;
}
limit_req_zone $auth_admission_key zone=auth_admission:1m rate=2r/s;
limit_req_status 429;
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
resolver 127.0.0.11 valid=5s ipv6=off;
location /api/ {
limit_req zone=auth_admission burst=5 nodelay;
set $backend http://backend:3000;
proxy_pass $backend$request_uri;
proxy_set_header Host $http_host;
+6 -1
View File
@@ -25,7 +25,11 @@ const readability = {
)
context.report({ messageId: "schema", node });
},
FunctionDeclaration: checkParameters,
FunctionDeclaration(node) {
checkParameters(node);
if (node.id?.name?.endsWith?.("ViewModel"))
context.report({ messageId: "vmName", node: node.id });
},
FunctionExpression: checkParameters,
ImportDeclaration(node) {
if (
@@ -95,6 +99,7 @@ const readability = {
schema: "Declare Zod schemas in a dedicated *.schema.ts file.",
svg: "Own SVG markup in packages/icons, and import its public component.",
union: "Put complex union members on separate lines.",
vmName: "Name custom hooks use*Vm, not use*ViewModel.",
},
schema: [],
type: "suggestion",
+1 -1
View File
@@ -5,7 +5,7 @@
<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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>WealthLedger</title>
</head>
<body>
+1 -1
View File
@@ -1,5 +1,5 @@
{
"ignoreDependencies": ["conventional-changelog-conventionalcommits"],
"ignoreDependencies": ["conventional-changelog-conventionalcommits", "minimatch"],
"workspaces": {
".": {
"project": ["src/**/*.{ts,tsx}", "!src/shared/api/generated/**"]
+1304 -2643
View File
File diff suppressed because it is too large Load Diff
+61 -52
View File
@@ -1,10 +1,10 @@
{
"name": "wealth-ledger-frontend",
"private": true,
"version": "0.4.0",
"version": "1.4.0",
"type": "module",
"engines": {
"node": "24.21.0"
"node": "24.22.1"
},
"scripts": {
"dev": "npm run build:packages && vite",
@@ -20,7 +20,7 @@
"test:coverage": "npm run build:packages && vitest run --coverage",
"test:lint": "node --test scripts/test-eslint.mjs",
"openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts",
"openapi:check": "node scripts/check-openapi.mjs",
"openapi:check": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts --check",
"knip": "npm run build:packages && knip",
"deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long",
"verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && eslint . --max-warnings=0 && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry",
@@ -34,91 +34,100 @@
"lint:fix": "npm run build:packages && eslint . --fix --max-warnings=0"
},
"lint-staged": {
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0"
"*.{js,mjs,cjs,ts,tsx}": "eslint --fix --max-warnings=0 --no-warn-ignored"
},
"dependencies": {
"@hookform/resolvers": "5.9.1",
"@mantine/charts": "9.6.1",
"@mantine/core": "9.6.1",
"@mantine/dates": "9.6.1",
"@mantine/hooks": "9.6.1",
"@tanstack/react-query": "5.102.8",
"@hookform/resolvers": "5.9.2",
"@mantine/charts": "9.7.3",
"@mantine/core": "9.7.3",
"@mantine/dates": "9.7.3",
"@mantine/hooks": "9.7.3",
"@mantine/notifications": "9.7.3",
"@tanstack/react-query": "5.104.5",
"@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",
"i18next": "26.5.10",
"motion": "13.5.1",
"openapi-fetch": "0.17.0",
"openapi-react-query": "0.5.4",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-hook-form": "7.88.0",
"react-i18next": "17.0.14",
"react-is": "19.3.0",
"react-router": "8.3.1",
"react": "19.3.1",
"react-dom": "19.3.1",
"react-hook-form": "7.91.0",
"react-i18next": "17.1.8",
"react-is": "19.3.1",
"react-router": "8.5.0",
"recharts": "3.10.1",
"zod": "4.6.4"
"zod": "4.6.7"
},
"devDependencies": {
"@babel/core": "8.0.5",
"@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2",
"@eslint-react/eslint-plugin": "5.21.0",
"@babel/core": "8.0.9",
"@commitlint/cli": "21.2.4",
"@commitlint/config-conventional": "21.2.4",
"@eslint-react/eslint-plugin": "5.28.8",
"@eslint/js": "10.0.1",
"@release-it/conventional-changelog": "12.0.0",
"@release-it/conventional-changelog": "12.0.2",
"@rolldown/plugin-babel": "0.2.4",
"@storybook/addon-docs": "10.6.0",
"@storybook/react-vite": "10.6.0",
"@storybook/addon-docs": "10.6.3",
"@storybook/react-vite": "10.6.3",
"@stylistic/eslint-plugin": "5.10.0",
"@tanstack/eslint-plugin-query": "5.104.0",
"@tanstack/eslint-plugin-query": "5.104.5",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@types/node": "24.13.4",
"@types/node": "24.19.2",
"@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",
"@vitejs/plugin-react": "6.1.2",
"@vitest/browser": "5.0.5",
"@vitest/browser-playwright": "5.0.5",
"@vitest/coverage-v8": "5.0.5",
"@vitest/eslint-plugin": "1.6.27",
"@vitest/ui": "5.0.5",
"babel-plugin-react-compiler": "1.0.0",
"conventional-changelog-conventionalcommits": "10.4.0",
"dependency-cruiser": "18.2.0",
"eslint": "10.11.0",
"conventional-changelog-conventionalcommits": "10.4.1",
"dependency-cruiser": "18.7.0",
"eslint": "10.13.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-perfectionist": "5.13.0",
"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-security": "4.2.0",
"eslint-plugin-sonarjs": "4.2.2",
"eslint-plugin-storybook": "10.6.3",
"eslint-plugin-testing-library": "7.16.2",
"eslint-plugin-unicorn": "76.0.0",
"globals": "17.12.0",
"globals": "17.13.0",
"husky": "9.1.7",
"jsdom": "30.0.1",
"knip": "6.35.1",
"lint-staged": "17.5.1",
"jsdom": "30.1.3",
"knip": "6.43.0",
"lint-staged": "17.6.0",
"minimatch": "file:scripts/minimatch-compat",
"msw": "2.15.0",
"openapi-typescript": "7.13.0",
"playwright": "1.63.0",
"release-it": "21.0.2",
"storybook": "10.6.0",
"playwright": "1.64.1",
"release-it": "21.1.1",
"storybook": "10.6.3",
"typescript": "5.9.3",
"typescript-eslint": "8.70.1",
"vite": "8.3.0",
"vite-plugin-pwa": "1.3.0",
"vitest": "5.0.0"
"typescript-eslint": "8.72.0",
"vite": "8.3.5",
"vite-plugin-pwa": "2.0.0",
"vitest": "5.0.5"
},
"overrides": {
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
}
"basic-ftp": "6.2.6",
"undici": "8.12.3"
},
"@vitest/browser": "5.0.5",
"@vitest/ui": "5.0.5",
"@vitest/mocker": "5.0.5",
"minimatch": "$minimatch",
"glob": "14.0.3"
},
"allowScripts": {
"esbuild@0.28.2": true,
+4 -4
View File
@@ -20,14 +20,14 @@
"build": "vite build && tsc -p tsconfig.json"
},
"peerDependencies": {
"react": "19.3.0"
"react": "19.3.1"
},
"devDependencies": {
"@storybook/react-vite": "10.6.0",
"@storybook/react-vite": "10.6.3",
"@testing-library/react": "16.3.3",
"@types/react": "19.3.0",
"typescript": "5.9.3",
"vite": "8.3.0",
"vitest": "5.0.0"
"vite": "8.3.5",
"vitest": "5.0.5"
}
}
+8 -8
View File
@@ -27,18 +27,18 @@
"@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"
"@mantine/core": "9.7.3",
"@mantine/hooks": "9.7.3",
"react": "19.3.1",
"react-dom": "19.3.1"
},
"devDependencies": {
"@storybook/react-vite": "10.6.0",
"@storybook/react-vite": "10.6.3",
"@testing-library/react": "16.3.3",
"@types/react": "19.3.0",
"storybook": "10.6.0",
"storybook": "10.6.3",
"typescript": "5.9.3",
"vite": "8.3.0",
"vitest": "5.0.0"
"vite": "8.3.5",
"vitest": "5.0.5"
}
}
+7 -2
View File
@@ -16,7 +16,7 @@ type EntryCardProps = {
tone: "negative"
| "neutral"
| "positive";
value: string;
value: ReactNode;
valueLabel: string;
wrap?: boolean;
};
@@ -32,6 +32,7 @@ const EntryCard: FC<EntryCardProps> = (props) => {
const valueContainerClass = props.actions ? classes.valueFooter : undefined;
const valueLabelClass = `${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`;
const valueComponent = typeof props.value === "string" ? undefined : "div";
return (
<Paper
@@ -63,7 +64,11 @@ const EntryCard: FC<EntryCardProps> = (props) => {
</Stack>
<Stack className={classes.value} gap={2}>
<Text className={classes[props.tone]} fw={700}>
<Text
className={classes[props.tone]}
component={valueComponent}
fw={700}
>
{props.value}
</Text>
+16 -1
View File
@@ -1,15 +1,30 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)"],
"extends": ["config:recommended", ":semanticCommitTypeAll(chore)", "security:minimumReleaseAgeNpm"],
"timezone": "Europe/Moscow",
"labels": ["dependencies"],
"dependencyDashboard": true,
"rangeStrategy": "pin",
"prConcurrentLimit": 5,
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 4am on the first day of the month"]
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major dependencies"
},
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major github actions"
},
{
"matchManagers": ["docker-compose", "dockerfile"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "non-major container images"
},
{
"matchUpdateTypes": ["major"],
"dependencyDashboardApproval": true
-45
View File
@@ -1,45 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import nodePath from "node:path";
const temporaryDirectory = await mkdtemp(
nodePath.join(tmpdir(), "wealth-ledger-openapi-"),
);
const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts");
const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts");
const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js");
let exitCode = 0;
try {
const generated = spawnSync(
process.execPath,
[cliPath, "src/shared/api/spec/openapi.json", "-o", generatedPath],
{ encoding: "utf8" },
);
if (generated.status === 0) {
const [committed, current] = await Promise.all([
readFile(committedPath, "utf8"),
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
readFile(generatedPath, "utf8"),
]);
if (committed !== current) {
console.error(
"Generated OpenAPI types are stale. Run `npm run openapi:generate` and commit the result.",
);
exitCode = 1;
}
}
else {
process.stderr.write(generated.stderr || generated.stdout);
exitCode = generated.status ?? 1;
}
}
finally {
await rm(temporaryDirectory, { force: true, recursive: true });
}
process.exitCode = exitCode;
-6
View File
@@ -6,12 +6,6 @@ const { version } = require("../package.json");
const git = (...arguments_) =>
execFileSync("git", arguments_, { encoding: "utf8" }).trim(); // eslint-disable-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell.
assert.equal(
git("status", "--porcelain"),
"",
"Commit all changes before preparing a release",
);
const tag_ = git(
"tag",
"--merged",
+9
View File
@@ -0,0 +1,9 @@
// ponytail: CommonJS twin of index.js (import-x ships lib/index.cjs too).
const minimatch = require("minimatch-11");
module.exports = {
...minimatch,
makeRe: (pattern, options = {}) => ({
test: path => minimatch.minimatch(path, pattern, options),
}),
};
+11
View File
@@ -0,0 +1,11 @@
// ponytail: shim for eslint-plugin-import-x@4.17.1, which still imports the
// makeRe export removed in minimatch 11. Its only caller (no-internal-modules)
// uses the result via .test(), so a matcher object is enough. Delete this
// package and the override once import-x supports minimatch 11.
import { minimatch } from "minimatch-11";
export * from "minimatch-11";
export const makeRe = (pattern, options = {}) => ({
test: path => minimatch(path, pattern, options),
});
+14
View File
@@ -0,0 +1,14 @@
{
"name": "minimatch",
"version": "11.0.2-compat.0",
"private": true,
"description": "minimatch 11 plus the makeRe export eslint-plugin-import-x 4.17.1 still imports. Remove when import-x supports minimatch 11.",
"type": "module",
"exports": {
"import": "./index.js",
"require": "./index.cjs"
},
"dependencies": {
"minimatch-11": "npm:minimatch@11.0.2"
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as
"security/detect-non-literal-regexp",
],
[
"scripts/check-openapi.mjs",
"scripts/test-pre-commit.mjs",
"import { exec } from \"node:child_process\"; exec(process.argv[2]);",
"security/detect-child-process",
],
+40
View File
@@ -47,6 +47,12 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
}
git("init", "--quiet");
// The hook must run the installed lint-staged binary, never download one.
const localBin = nodePath.join(cwd, "node_modules", ".bin");
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(localBin, { recursive: true });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(localBin, "lint-staged"), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n");
git("add", "sample.txt");
@@ -77,3 +83,37 @@ test("pre-commit scans the index, redacts findings and fails closed", () => {
rmSync(cwd, { force: true, recursive: true });
}
});
test("pre-commit never downloads tools and blocks when lint-staged is missing", () => {
const hook = nodePath.resolve(".husky/pre-commit");
const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-"));
const bin = nodePath.join(cwd, "bin");
const marker = nodePath.join(cwd, "downstream");
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
mkdirSync(bin);
// Gitleaks passes; npx/npm must never be reached, even if present on PATH.
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, "gitleaks"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
for (const command of ["npx", "npm"]) {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755 });
}
const result = spawnSync("/bin/sh", ["-e", hook], {
cwd,
encoding: "utf8",
env: { ...process.env, PATH: `${bin}${nodePath.delimiter}${process.env.PATH}` },
});
assert.notEqual(result.status, 0, "missing lint-staged must block commit");
assert.match(result.stderr, /lint-staged is not installed/);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames.
assert.ok(!existsSync(marker), "hook must not invoke npx or npm");
}
finally {
rmSync(cwd, { force: true, recursive: true });
}
});
+72 -34
View File
@@ -11,19 +11,28 @@ import {
} from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { MemoryRouter, useLocation } from "react-router";
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } 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 { defaultPeriod, formatDate } from "@/shared/lib";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AppRoutes } from "./AppRoutes";
// Mantine calendar labels follow dayjs default (English) locale.
function dayLabel(date: string) {
const monthName = new Intl.DateTimeFormat("en", { month: "long" })
.format(new Date(`${date}T12:00:00Z`));
return `${Number(date.slice(8))} ${monthName} ${date.slice(0, 4)}`;
}
const accounts = [
{ id: "a", name: "Основной" },
{ id: "b", name: "ИИС" },
@@ -37,19 +46,21 @@ const position: Portfolio["positions"][number]
displayName: "Сбербанк",
figi: "SBER",
isin: null,
issuer: null,
logoName: null,
quantity: "2",
sector: null,
ticker: "SBER",
type: "STOCK",
};
const portfolio: Portfolio = {
cashBalances: [],
cash: { currency: "RUB", value: "0" },
isStale: false,
lastSuccessfulAt: "2026-08-15T12:00:00Z",
positions: accounts.map(account => ({ ...position, account })),
structure: { currencies: [], unvaluedCategories: [] },
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
structure: { categories: [], hasNegativeValues: false, totalValue: "0", unvaluedCategories: [] },
totals: { currency: "RUB", value: "1200" },
};
const receipt: BrokerageOperation
@@ -95,19 +106,11 @@ const analytics: IncomeAnalytics
},
],
period: { from: "2026-08-01", to: "2026-08-31" },
previous: {
period: { from: "2026-07-01", to: "2026-07-31" },
totalsByCurrency: [],
total: {
coupon: { currency: "RUB", value: "2" },
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
},
totalsByCurrency: [
{
coupon: { currency: "RUB", value: "2" },
currency: "RUB",
dividend: { currency: "RUB", value: "0" },
total: { currency: "RUB", value: "2" },
},
],
yearComparison: null,
};
const CurrentLocation: FC = () => {
@@ -141,6 +144,7 @@ function renderPage(path: string) {
return client;
}
afterEach(() => vi.useRealTimers());
beforeEach(() => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
@@ -209,7 +213,8 @@ beforeEach(() => {
type: "coupon",
}),
),
incomeTotalsByCurrency: [],
incomeTotal: { currency: "RUB", value: "0" },
window: { from: "2026-09-01", to: "2026-10-01" },
}),
),
),
@@ -219,9 +224,9 @@ beforeEach(() => {
describe("All accounts routes", () => {
it.each([
["/accounts/all", "Все счета"],
["/accounts/all/operations", "Журнал операций"],
["/accounts/all/operations", "История операций"],
["/accounts/all/income?from=2026-08-01&to=2026-08-31", "Пассивный доход"],
["/accounts/all/events", "Будущие события"],
["/accounts/all/events", "Предстоящие события"],
])("opens %s directly", async (path, title) => {
renderPage(path);
expect(
@@ -238,7 +243,7 @@ describe("All accounts routes", () => {
);
});
it.each(["operations", "events", "income"])(
it.each(["operations", "income"])(
"restores applied dates when switching accounts in %s",
async (section) => {
const search = "?from=2026-08-01&to=2026-08-31";
@@ -266,6 +271,33 @@ describe("All accounts routes", () => {
},
);
it("restores applied dates when switching accounts in events", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-10-15T12:00:00Z"));
// The events calendar blocks past dates, so pick within selectable dates.
const search = `?from=${defaultPeriod()[0]}&to=${defaultPeriod()[1]}`;
renderPage(`/accounts/all/events${search}`);
const picker = await screen.findByLabelText("Период");
fireEvent.click(picker);
const [fromDate, toDate] = defaultPeriod();
fireEvent.click(
await screen.findByRole("button", { name: dayLabel(fromDate) }),
);
fireEvent.click(
await screen.findByRole("button", { name: dayLabel(toDate) }),
);
expect(screen.getByLabelText("Период")).toHaveTextContent(
`${formatDate(fromDate)} – ${formatDate(toDate)}`,
);
fireEvent.click(screen.getByRole("combobox", { name: "Счёт" }));
fireEvent.click(await screen.findByRole("option", { name: "ИИС" }));
await waitFor(() =>
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
`/accounts/b/events${search}`,
),
);
});
it("groups the same security and links its accounts and overview sections", async () => {
renderPage("/accounts/all");
expect(await screen.findAllByText("Сбербанк")).toHaveLength(1);
@@ -274,14 +306,16 @@ describe("All accounts routes", () => {
"href",
`/accounts/${account.id}`,
);
for (const suffix of ["operations", "income", "events"])
expect(
screen
.getAllByRole("link")
.find(
link => link.getAttribute("href") === `/accounts/all/${suffix}`,
),
).toBeInTheDocument();
for (const [suffix, tab] of [
["operations", "История операций"],
["income", "Доход"],
["events", "Предстоящие события"],
]) {
fireEvent.click(screen.getByRole("tab", { name: tab }));
expect(screen.getByRole("link", { name: "Подробнее" }))
.toHaveAttribute("href", `/accounts/all/${suffix}`);
}
});
it("switches accounts within operations with applied filters and a fresh cursor", async () => {
@@ -369,7 +403,9 @@ describe("All accounts routes", () => {
renderPage("/accounts/all/operations?from=2026-08-01&to=2026-08-31");
await screen.findAllByText("Выплата купона");
fireEvent.click(screen.getByRole("button", { name: "Далее" }));
fireEvent.click(await screen.findByRole("button", { name: "Повторить" }));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Повторить" })).not.toBeInTheDocument();
});
expect(await screen.findAllByText("Выплата купона")).toHaveLength(2);
expect(screen.getByLabelText("Текущий путь")).toHaveTextContent(
"from=2026-08-01&to=2026-08-31",
@@ -390,10 +426,12 @@ describe("All accounts routes", () => {
}),
);
expect(screen.getAllByText("Сбербанк")).toHaveLength(1);
expect(
await screen.findByRole("button", {
name: "Повторить",
}),
).toBeInTheDocument();
await waitFor(() => {
expect(
screen.queryByRole("button", {
name: "Повторить",
}),
).not.toBeInTheDocument();
});
});
});
+27 -2
View File
@@ -190,6 +190,30 @@ describe("AppRoutes", () => {
expect(document.title).toBe("Карты — WealthLedger");
});
it("offers a retry instead of the login form while the server is unreachable, then restores the route", async () => {
let isOutage = true;
server.use(
http.post("*/api/v1/auth/refresh", () =>
isOutage
? new HttpResponse(null, { status: 502 })
: HttpResponse.json({
accessToken: "test-token",
user: { email: "user@example.com", id: "user-1" },
}),
),
);
renderRoutes("/accounts");
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось подключиться");
expect(screen.queryByRole("heading", { name: "Вход" })).not.toBeInTheDocument();
isOutage = false;
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
expect(
await screen.findByRole("heading", { level: 2, name: "Нет инвестиционных счетов" }),
).toBeVisible();
});
it("lazy-loads the protected shell for an authenticated accounts route", async () => {
server.use(
http.post("*/api/v1/auth/refresh", () =>
@@ -208,7 +232,8 @@ describe("AppRoutes", () => {
}),
).toBeInTheDocument();
expect(
screen.getByRole("navigation", { name: "WealthLedger" }),
).toBeInTheDocument();
screen.getAllByRole("navigation", { name: "Основная навигация" }),
).toHaveLength(2);
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
});
});
+28 -94
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import type { ComponentType, FC } from "react";
import { lazy } from "react";
import { Navigate, Route, Routes } from "react-router";
@@ -6,101 +6,35 @@ import { Navigate, Route, Routes } from "react-router";
import { LazyPage } from "../RouteFallback";
import { Protected, Public } from "../RouteGuards";
const CardPage = lazy(async () => {
const { CardPage } = await import("@/pages/card");
// Pages use named exports; React.lazy needs a default export per dynamic import (one chunk each).
function lazyPage<Name extends string>(
load: () => Promise<Record<Name, ComponentType>>,
name: Name,
) {
return lazy(async () => {
const page = await load();
return { default: CardPage };
});
// eslint-disable-next-line security/detect-object-injection -- `name` is a compile-time literal checked against the module's exports.
return { default: page[name] };
});
}
const CardsPage = lazy(async () => {
const { CardsPage } = await import("@/pages/cards");
return { default: CardsPage };
});
const BondPage = lazy(async () => {
const { BondPage } = await import("@/pages/bond");
return { default: BondPage };
});
const BondsPage = lazy(async () => {
const { BondsPage } = await import("@/pages/bonds");
return { default: BondsPage };
});
const StockPage = lazy(async () => {
const { StockPage } = await import("@/pages/stock");
return { default: StockPage };
});
const StocksPage = lazy(async () => {
const { StocksPage } = await import("@/pages/stocks");
return { default: StocksPage };
});
const AccountPage = lazy(async () => {
const { AccountPage } = await import("@/pages/account");
return { default: AccountPage };
});
const AccountEventsPage = lazy(async () => {
const { AccountEventsPage } = await import("@/pages/account-events");
return { default: AccountEventsPage };
});
const AccountOperationsPage = lazy(async () => {
const { AccountOperationsPage } = await import("@/pages/account-operations");
return { default: AccountOperationsPage };
});
const AccountIncomePage = lazy(async () => {
const { AccountIncomePage } = await import("@/pages/account-income");
return { default: AccountIncomePage };
});
const AccountsPage = lazy(async () => {
const { AccountsPage } = await import("@/pages/accounts");
return { default: AccountsPage };
});
const LoginPage = lazy(async () => {
const { LoginPage } = await import("@/pages/login");
return { default: LoginPage };
});
const NotFoundPage = lazy(async () => {
const { NotFoundPage } = await import("@/pages/not-found");
return { default: NotFoundPage };
});
const RegisterPage = lazy(async () => {
const { RegisterPage } = await import("@/pages/register");
return { default: RegisterPage };
});
const SettingsPage = lazy(async () => {
const { SettingsPage } = await import("@/pages/settings");
return { default: SettingsPage };
});
const ProtectedLayout = lazy(async () => {
const { ProtectedLayout } = await import("../ProtectedLayout");
return { default: ProtectedLayout };
});
const CardPage = lazyPage(async () => import("@/pages/card"), "CardPage");
const CardsPage = lazyPage(async () => import("@/pages/cards"), "CardsPage");
const BondPage = lazyPage(async () => import("@/pages/bond"), "BondPage");
const BondsPage = lazyPage(async () => import("@/pages/bonds"), "BondsPage");
const StockPage = lazyPage(async () => import("@/pages/stock"), "StockPage");
const StocksPage = lazyPage(async () => import("@/pages/stocks"), "StocksPage");
const AccountPage = lazyPage(async () => import("@/pages/account"), "AccountPage");
const AccountEventsPage = lazyPage(async () => import("@/pages/account-events"), "AccountEventsPage");
const AccountOperationsPage = lazyPage(async () => import("@/pages/account-operations"), "AccountOperationsPage");
const AccountIncomePage = lazyPage(async () => import("@/pages/account-income"), "AccountIncomePage");
const AccountsPage = lazyPage(async () => import("@/pages/accounts"), "AccountsPage");
const LoginPage = lazyPage(async () => import("@/pages/login"), "LoginPage");
const NotFoundPage = lazyPage(async () => import("@/pages/not-found"), "NotFoundPage");
const RegisterPage = lazyPage(async () => import("@/pages/register"), "RegisterPage");
const SettingsPage = lazyPage(async () => import("@/pages/settings"), "SettingsPage");
const ProtectedLayout = lazyPage(async () => import("../ProtectedLayout"), "ProtectedLayout");
const AppRoutes: FC = () => (
<Routes>
@@ -1,6 +1,5 @@
import type { FC } from "react";
import { useDocumentTitle } from "@mantine/hooks";
import { useTranslation } from "react-i18next";
import { useCurrentRoute } from "@/app/model";
@@ -8,12 +7,12 @@ import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStock } from "@/entities/stock";
import { useAppDocumentTitle } from "@/shared/ui";
type DocumentTitleProps = { title: string };
const DocumentTitle: FC<DocumentTitleProps> = (props): undefined => {
const { t } = useTranslation();
useDocumentTitle(`${props.title} — ${t("appName")}`);
useAppDocumentTitle(props.title);
};
type AccountDocumentTitleProps = {
@@ -1,16 +1,15 @@
import type { FC, PropsWithChildren } from "react";
import { Button, Stack, Text, Title } from "@mantine/core";
import { useDocumentTitle } from "@mantine/hooks";
import { Component } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
import { useAppDocumentTitle } from "@/shared/ui";
type BoundaryProps = PropsWithChildren<{
authenticated: boolean;
routeKey: string;
}>;
type RouteErrorFallbackProps = {
@@ -26,12 +25,6 @@ class Boundary extends Component<BoundaryProps, State> {
override state: State = { hasError: false };
override componentDidUpdate(previousProps: BoundaryProps) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
override render() {
return this.state.hasError
? <RouteErrorFallback authenticated={this.props.authenticated} />
@@ -44,7 +37,7 @@ class Boundary extends Component<BoundaryProps, State> {
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
const { authenticated } = props;
const { t } = useTranslation();
useDocumentTitle(`${t("routeError.title")} — ${t("appName")}`);
useAppDocumentTitle(t("routeError.title"));
const to = authenticated ? "/accounts" : "/login";
return (
@@ -70,10 +63,7 @@ const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
const { user } = useAuth();
return (
<Boundary
authenticated={Boolean(user)}
routeKey={`${pathname}${search}:${key}`}
>
<Boundary authenticated={Boolean(user)} key={`${pathname}${search}:${key}`}>
{children}
</Boundary>
);
+18 -1
View File
@@ -1,4 +1,7 @@
import { Button, Container } from "@mantine/core";
import { StateView } from "@wealthledger/ui";
import { type FC, type PropsWithChildren, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
@@ -7,12 +10,26 @@ import { RouteSkeleton } from "./RouteFallback";
const Protected: FC<PropsWithChildren> = (props) => {
const { children } = props;
const { ready, signedOut, user } = useAuth();
const { ready, retry, signedOut, unavailable, user } = useAuth();
const { t } = useTranslation();
const location = useLocation();
const [wasAuthenticated, setWasAuthenticated] = useState(false);
if (user && !wasAuthenticated) setWasAuthenticated(true);
if (!ready) return <RouteSkeleton variant="shell" />;
// The server was unreachable (e.g. mid-deploy), so the session is unproven, not gone.
if (!user && unavailable)
return (
<Container py="xl" size={560}>
<StateView
action={<Button onClick={retry}>{t("auth.retry")}</Button>}
error
text={t("auth.unavailableText")}
title={t("auth.unavailableTitle")}
/>
</Container>
);
const loginState = signedOut && wasAuthenticated
? undefined
: { returnTo: `${location.pathname}${location.search}` };
+8 -1
View File
@@ -2,6 +2,7 @@ import type { FC } from "react";
import { MantineProvider } from "@mantine/core";
import { DatesProvider } from "@mantine/dates";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cssVariablesResolver, theme } from "@wealthledger/ui/theme";
import { BrowserRouter } from "react-router";
@@ -11,7 +12,11 @@ import { AuthProvider } from "@/features/auth";
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
queries: {
refetchOnReconnect: true,
refetchOnWindowFocus: true,
retry: false,
},
},
});
@@ -31,6 +36,8 @@ const App: FC = () => {
</RouteErrorBoundary>
</AuthProvider>
</BrowserRouter>
<Notifications autoClose={4000} limit={2} position="top-right" zIndex={1000} />
</QueryClientProvider>
</DatesProvider>
</MantineProvider>
@@ -55,9 +55,9 @@ describe("AppBreadcrumbs", () => {
});
it.each([
["operations", "Операции"],
["OPERATIONS", "Операции"],
["events", "Будущие события"],
["operations", "История операций"],
["OPERATIONS", "История операций"],
["events", "Предстоящие события"],
["income", "Пассивный доход"],
])("links to the account hierarchy from %s", (section, title) => {
renderBreadcrumbs(`/accounts/account-1/${section}`, [
+17 -7
View File
@@ -7,10 +7,8 @@ import { useCurrentRoute } from "@/app/model";
import { AccountBreadcrumbs } from "./AccountBreadcrumbs";
import { AccountScopeSwitch } from "./AccountScopeSwitch/AccountScopeSwitch";
import { BondBreadcrumbs } from "./BondBreadcrumbs";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
import { CardBreadcrumbs } from "./CardBreadcrumbs";
import { StockBreadcrumbs } from "./StockBreadcrumbs";
import { EntityBreadcrumbs } from "./EntityBreadcrumbs";
const AppBreadcrumbs: FC = () => {
const { t } = useTranslation();
@@ -26,9 +24,16 @@ const AppBreadcrumbs: FC = () => {
stock,
} = useCurrentRoute();
if (bond) return <BondBreadcrumbs identifier={bond.params.bondId ?? ""} />;
if (stock)
return <StockBreadcrumbs identifier={stock.params.stockId ?? ""} />;
if (bond) {
return <EntityBreadcrumbs identifier={bond.params.bondId ?? ""} kind="bond" />;
}
if (stock) {
return (
<EntityBreadcrumbs identifier={stock.params.stockId ?? ""} kind="stock" />
);
}
if (account && (section || !account.params.section))
return (
<Stack gap={0} mb="md">
@@ -43,8 +48,13 @@ const AppBreadcrumbs: FC = () => {
/>
</Stack>
);
if (card) return <CardBreadcrumbs identifier={card.params.cardId ?? ""} />;
if (card) {
return <EntityBreadcrumbs identifier={card.params.cardId ?? ""} kind="card" />;
}
if (cards) return <BreadcrumbTrail items={[{ label: t("bank.title") }]} />;
if (accounts)
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.accounts") }]} />;
@@ -1,25 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBond } from "@/entities/bond";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type BondBreadcrumbsProps = { identifier: string };
const BondBreadcrumbs: FC<BondBreadcrumbsProps> = (props) => {
const { data } = useBond(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bond.searchTitle"), to: "/bonds" },
{ label: data?.name ?? t("bond.instrument") },
]}
/>
);
};
export { BondBreadcrumbs };
@@ -39,4 +39,4 @@ const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
);
};
export { BreadcrumbTrail };
export { BreadcrumbTrail, type Crumb };
@@ -1,25 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type CardBreadcrumbsProps = { identifier: string };
const CardBreadcrumbs: FC<CardBreadcrumbsProps> = (props) => {
const { data } = useBankCard(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("bank.title"), to: "/cards" },
{ label: data?.name ?? t("bank.card") },
]}
/>
);
};
export { CardBreadcrumbs };
@@ -0,0 +1,48 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useBankCard } from "@/entities/bank-card";
import { useBond } from "@/entities/bond";
import { useStock } from "@/entities/stock";
import type { Crumb } from "./BreadcrumbTrail";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type EntityBreadcrumbsProps = {
identifier: string;
kind:
| "bond"
| "card"
| "stock";
};
const EntityBreadcrumbs: FC<EntityBreadcrumbsProps> = (props) => {
const { identifier, kind } = props;
const { t } = useTranslation();
const bond = useBond(kind === "bond" ? identifier : "");
const card = useBankCard(kind === "card" ? identifier : "");
const stock = useStock(kind === "stock" ? identifier : "");
let parent: Crumb;
let name: string;
if (kind === "bond") {
parent = { label: t("bond.searchTitle"), to: "/bonds" };
name = bond.data?.name ?? t("bond.instrument");
}
else if (kind === "stock") {
parent = { label: t("stock.searchTitle"), to: "/stocks" };
name = stock.data?.name ?? t("stock.share");
}
else {
parent = { label: t("bank.title"), to: "/cards" };
name = card.data?.name ?? t("bank.card");
}
return <BreadcrumbTrail items={[parent, { label: name }]} />;
};
export { EntityBreadcrumbs };
@@ -1,25 +0,0 @@
import type { FC } from "react";
import { useTranslation } from "react-i18next";
import { useStock } from "@/entities/stock";
import { BreadcrumbTrail } from "./BreadcrumbTrail";
type StockBreadcrumbsProps = { identifier: string };
const StockBreadcrumbs: FC<StockBreadcrumbsProps> = (props) => {
const { data } = useStock(props.identifier);
const { t } = useTranslation();
return (
<BreadcrumbTrail
items={[
{ label: t("stock.searchTitle"), to: "/stocks" },
{ label: data?.name ?? t("stock.share") },
]}
/>
);
};
export { StockBreadcrumbs };
+84 -18
View File
@@ -1,12 +1,22 @@
.root {
--bottom-nav-height: 56px;
}
.content {
max-width: 1120px;
margin-inline: auto;
}
.header {
.brand {
align-items: center;
border-bottom: 1px solid var(--mantine-color-gray-3);
display: flex;
padding: 0 var(--mantine-spacing-md);
min-height: 60px;
padding-inline: var(--mantine-spacing-sm);
}
.navList {
padding: var(--mantine-spacing-sm);
}
.bottomNav {
@@ -52,29 +62,85 @@
.bottomNav {
background: var(--mantine-color-white);
border-top: 1px solid var(--mantine-color-gray-3);
bottom: 0;
display: flex;
justify-content: space-around;
left: 0;
padding: var(--mantine-spacing-xs) 2px;
gap: 2px;
font-size: 11px;
& a {
min-width: 0;
padding: 8px 2px;
text-align: center;
}
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
inset: auto 0 0;
min-height: calc(var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-inline:
max(4px, env(safe-area-inset-left, 0px))
max(4px, env(safe-area-inset-right, 0px));
position: fixed;
right: 0;
z-index: 200;
z-index: var(--mantine-z-index-app);
}
.bottomNavItem {
align-items: center;
color: var(--mantine-color-gray-7);
display: flex;
flex-direction: column;
gap: 2px;
justify-content: center;
min-height: var(--bottom-nav-height);
min-width: 0;
text-decoration: none;
&:active .iconPill {
background-color: var(--mantine-color-gray-1);
}
&:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -2px;
}
}
@media (hover: hover) {
.bottomNavItem:hover .iconPill {
background-color: var(--mantine-color-gray-1);
}
}
.iconPill {
align-items: center;
border-radius: 14px;
display: inline-flex;
height: 28px;
justify-content: center;
width: 48px;
}
.bottomNavLabel {
font-size: 12px;
font-weight: 500;
line-height: 16px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: no-preference) {
.bottomNavItem,
.iconPill {
transition:
background-color 150ms ease-in-out,
color 150ms ease-in-out;
}
}
.active {
color: var(--mantine-primary-color-filled);
font-weight: 600;
& .iconPill {
background-color: var(--mantine-primary-color-light);
}
}
.main {
padding-bottom: 5rem;
padding-bottom: calc(
var(--bottom-nav-height) + env(safe-area-inset-bottom, 0px) +
var(--mantine-spacing-md)
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { AppShell } from "./AppShell";
function renderShell(path: string) {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<AppShell />
</MemoryRouter>
</MantineProvider>,
);
}
describe("AppShell", () => {
it("has no header banner", () => {
renderShell("/cards");
expect(screen.queryByRole("banner")).not.toBeInTheDocument();
});
it("marks only the current bottom navigation item", () => {
renderShell("/cards");
const bottomNav = screen
.getAllByRole("navigation", { name: "Основная навигация" })
.at(-1);
if (!bottomNav) {
throw new Error("Bottom navigation is not rendered");
}
const links = within(bottomNav).getAllByRole("link");
expect(links).toHaveLength(5);
expect(within(bottomNav).getByRole("link", { name: "Карты" })).toHaveAttribute(
"aria-current",
"page",
);
expect(
within(bottomNav).getByRole("link", { name: "Счета" }),
).not.toHaveAttribute("aria-current");
});
});
+49 -42
View File
@@ -17,6 +17,12 @@ type AppShellProps = {
contentAboveOutlet?: ReactNode;
};
function getBottomNavItemClass({ isActive }: { isActive: boolean }) {
return isActive
? `${classes.bottomNavItem} ${classes.active}`
: classes.bottomNavItem;
}
const AppShell: FC<AppShellProps> = (props) => {
const { contentAboveOutlet } = props;
const { t } = useTranslation();
@@ -24,31 +30,31 @@ const AppShell: FC<AppShellProps> = (props) => {
const items = [
{
icon: <PortfolioIcon size={20} />,
Icon: PortfolioIcon,
iconClassName: classes.portfolioIcon,
label: t("navigation.accounts"),
to: "/accounts",
},
{
icon: <CardsIcon size={20} />,
Icon: CardsIcon,
iconClassName: classes.cardsIcon,
label: t("navigation.cards"),
to: "/cards",
},
{
icon: <StocksIcon size={20} />,
Icon: StocksIcon,
iconClassName: classes.stocksIcon,
label: t("navigation.stocks"),
to: "/stocks",
},
{
icon: <BondsIcon size={20} />,
Icon: BondsIcon,
iconClassName: classes.bondsIcon,
label: t("navigation.bonds"),
to: "/bonds",
},
{
icon: <SettingsIcon size={20} />,
Icon: SettingsIcon,
iconClassName: classes.settingsIcon,
label: t("navigation.settings"),
to: "/settings",
@@ -57,32 +63,32 @@ const AppShell: FC<AppShellProps> = (props) => {
return (
<MantineAppShell
header={{ height: 60 }}
className={classes.root}
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 aria-label={t("navigation.primary")}>
<div className={classes.brand}>
<Text fw={700}>{t("appName")}</Text>
</div>
<MantineAppShell.Navbar p="sm">
{items.map(item => (
<NavLink
active={pathname.startsWith(item.to)}
className={classes.navLink}
component={RouterNavLink}
key={item.to}
label={item.label}
leftSection={(
<span
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
>
{item.icon}
</span>
)}
to={item.to}
/>
))}
<div className={classes.navList}>
{items.map(item => (
<NavLink
active={pathname.startsWith(item.to)}
className={classes.navLink}
component={RouterNavLink}
key={item.to}
label={item.label}
leftSection={(
<span className={`${classes.navIcon} ${item.iconClassName}`}>
<item.Icon size={20} />
</span>
)}
to={item.to}
/>
))}
</div>
</MantineAppShell.Navbar>
<MantineAppShell.Main className={classes.main}>
@@ -93,22 +99,23 @@ const AppShell: FC<AppShellProps> = (props) => {
</div>
</MantineAppShell.Main>
<nav aria-label={t("appName")} className={classes.bottomNav}>
{items.map((item) => {
const appearanceClass = pathname.startsWith(item.to)
? classes.active
: undefined;
<nav
aria-label={t("navigation.primary")}
className={classes.bottomNav}
>
{items.map(item => (
<RouterNavLink
className={getBottomNavItemClass}
key={item.to}
to={item.to}
>
<span className={classes.iconPill}>
<item.Icon size={24} />
</span>
return (
<RouterNavLink
className={appearanceClass}
key={item.to}
to={item.to}
>
{item.label}
</RouterNavLink>
);
})}
<span className={classes.bottomNavLabel}>{item.label}</span>
</RouterNavLink>
))}
</nav>
</MantineAppShell>
);
+2 -2
View File
@@ -147,7 +147,7 @@ describe("banking API", () => {
expect(url.searchParams.get("from")).toBe("2026-09-01");
expect(url.searchParams.get("to")).toBe("2026-09-30");
if (isFail && page === 2)
return HttpResponse.json({ code: "FAILED" }, { status: 500 });
return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 });
return HttpResponse.json({
hasNext: page === 1,
@@ -299,7 +299,7 @@ describe("banking API", () => {
expect(result.current.query.data?.balance.value).toBe("100");
server.use(
http.get("*/api/v1/bank-cards/a", () =>
HttpResponse.json({ code: "INTERNAL_ERROR" }, { status: 500 }),
HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }),
),
);
await act(() => result.current.query.refetch());
+4 -10
View File
@@ -5,6 +5,7 @@ import {
ApiErrorCode,
apiErrorCode,
isApiErrorCode,
isGetUnder,
} from "@/shared/api";
import type {
@@ -26,21 +27,14 @@ async function mutationData<T>(
return data;
}
const bankCardGetPredicate = isGetUnder(["/api/v1/bank-cards"]);
function useBankMutationOptions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({
predicate: ({ queryKey }) => {
const [method, path] = queryKey;
return (
method === "get"
&& typeof path === "string"
&& (path === "/api/v1/bank-cards"
|| path.startsWith("/api/v1/bank-cards/"))
);
},
predicate: ({ queryKey }) => bankCardGetPredicate(queryKey),
});
return {
@@ -0,0 +1,7 @@
function errorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}
export { errorCode };
+3 -1
View File
@@ -6,6 +6,7 @@ import { calendarDate } from "@/shared/lib";
import { bankToday } from "./form-validation";
import { moneyCents } from "./money";
const MAX_CARD_NAME_LENGTH = 100;
const paymentSystemValues = ["mir", "visa", "mastercard"] as const;
const MIN_BALANCE_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_BALANCE_KOPECKS = 9_223_372_036_854_775_807n;
@@ -63,7 +64,7 @@ const bankCardSchema = z.object({
openingBalance: moneySchema,
openingDate: bankDateSchema,
paymentSystem: z
.enum(["", "mir", "visa", "mastercard"], {
.enum(["", ...paymentSystemValues], {
error: validationMessage("bank.validation.paymentSystem"),
})
.optional(),
@@ -75,5 +76,6 @@ export {
bankCardSchema,
type BankCardValues,
bankDateSchema,
paymentSystemValues,
positiveMoneySchema,
};
@@ -1,8 +1,7 @@
import { useController, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { BankCardValues } from "../../model/form.schema";
import { type BankCardValues, paymentSystemValues } from "../../model/form.schema";
import { bankDateField } from "../bankDateField";
function useBankCardFieldsVm() {
@@ -16,7 +15,7 @@ function useBankCardFieldsVm() {
const { field: openingDateField } = useController({ control, name: "openingDate" });
const { field: paymentSystemField } = useController({ control, name: "paymentSystem" });
const paymentSystems = ["mir", "visa", "mastercard"].map(value => ({
const paymentSystems = paymentSystemValues.map(value => ({
label: t(`bank.paymentSystems.${value}`),
value,
}));
@@ -1,4 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { notifications } from "@mantine/notifications";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -71,7 +72,7 @@ function useBankCardFormVm(props: BankCardFormProps) {
openingDate: values.openingDate,
};
if (props.card)
if (props.card) {
await updateCard.mutateAsync({
body: {
...metadata,
@@ -80,12 +81,19 @@ function useBankCardFormVm(props: BankCardFormProps) {
},
id: props.card.id,
});
else
}
else {
await cardCreation.mutateAsync({
id: intent,
...metadata,
...financial,
});
notifications.show({
id: "bank-card-created-" + intent,
message: t("bank.cardSaved"),
});
}
setOpened(false);
}
catch (error_) {
+2 -2
View File
@@ -3,7 +3,7 @@ import type { FC } from "react";
import { Alert, Button, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { apiErrorCode } from "@/shared/api";
import { errorCode } from "../model/error-code";
type BankFeedbackProps = {
cached?: boolean;
@@ -14,7 +14,7 @@ type BankFeedbackProps = {
const BankFeedback: FC<BankFeedbackProps> = (props) => {
const { t } = useTranslation();
const code = apiErrorCode(props.error);
const code = errorCode(props.error);
return (
<Alert color="orange" role="alert">
@@ -15,6 +15,28 @@
min-width: 0;
}
.totals {
display: flex;
flex-wrap: wrap;
gap: var(--mantine-spacing-xs) var(--mantine-spacing-lg);
margin: 0;
}
.totals > div {
display: grid;
gap: 0;
}
.totals dt {
color: var(--mantine-color-dimmed);
font-size: var(--mantine-font-size-xs);
}
.totals dd {
margin: 0;
font-variant-numeric: tabular-nums;
}
.categories {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -47,7 +47,6 @@ const meta
error: false,
fetching: false,
loading: false,
retry: () => {},
},
component: BankSummaryView,
parameters: {
@@ -0,0 +1,35 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BankSummaryView } from "./BankSummary.view";
const money = (value: string) => ({ currency: "RUB", value });
describe("BankSummaryView", () => {
it("shows expenses, refunds, and net monthly totals", () => {
render(
<MantineProvider env="test">
<BankSummaryView
data={{
cardId: "card-1",
categories: [],
expenses: money("1000"),
net: money("800"),
refunds: money("200"),
}}
error={false}
fetching={false}
loading={false}
/>
</MantineProvider>,
);
expect(screen.getAllByText("Расходы")).toHaveLength(2);
expect(screen.getByText("Возвращено")).toBeInTheDocument();
expect(screen.getByText("После возвратов")).toBeInTheDocument();
expect(screen.getByText("1 000,00 ₽")).toBeInTheDocument();
expect(screen.getByText("200,00 ₽")).toBeInTheDocument();
expect(screen.getByText("800,00 ₽")).toBeInTheDocument();
});
});
@@ -1,6 +1,6 @@
import type { CSSProperties, FC } from "react";
import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core";
import { Skeleton, Stack, Text, Title } from "@mantine/core";
import { OperationIcon } from "@wealthledger/icons";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
@@ -10,6 +10,7 @@ import { formatMoney } from "@/shared/lib";
import type { BankSummary } from "../../api/types";
import { bankOperationAppearance } from "../../model/operation-appearance";
import { BankFeedback } from "../BankFeedback";
import classes from "./BankSummary.module.css";
const CATEGORY_COLUMNS = 2;
@@ -17,10 +18,10 @@ const CATEGORY_COLUMNS = 2;
type BankSummaryViewProps = {
all?: boolean;
data?: BankSummary;
error: boolean;
error: unknown;
fetching: boolean;
loading: boolean;
retry: () => void;
retry?: () => void;
};
const BankSummaryViewCategories: FC<BankSummaryViewProps> = (props) => {
@@ -93,25 +94,44 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Title>
{props.data && (
<Text className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</Text>
<dl className={classes.totals}>
<div>
<dt>{t("bank.summary")}</dt>
<dd>
{formatMoney(props.data.expenses, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.refunds")}</dt>
<dd>
{formatMoney(props.data.refunds, { withZeroCents: true })}
</dd>
</div>
<div>
<dt>{t("bank.net")}</dt>
<dd className={classes.total}>
{formatMoney(props.data.net, { withZeroCents: true })}
</dd>
</div>
</dl>
)}
</div>
<BankSummaryViewCategories {...props} />
{props.error && (
<Group role="alert">
<Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text>
<Button onClick={props.retry} variant="default">
{t("bank.retry")}
</Button>
</Group>
{Boolean(props.error) && (
<BankFeedback
cached={Boolean(props.data)}
error={props.error}
loadingError={!props.data}
retry={props.retry}
/>
)}
{props.data && props.fetching && <Text role="status">{t("bank.refreshing")}</Text>}
</Stack>
);
};
+2
View File
@@ -5,5 +5,7 @@ export {
type OperationCategory,
useBrokerageOperations,
} from "./api/useBrokerageOperations";
export { brokerageOperationGroupKey } from "./model/display";
export { useBrokerageOperationHistory } from "./model/useBrokerageOperationHistory";
export { LedgerEntryRow } from "./ui/LedgerEntryRow";
export { OperationGroup } from "./ui/OperationGroup";
@@ -1,9 +1,12 @@
import { expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import { operationLabelKey } from "./display";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { brokerageOperationGroupKey, operationLabelKey } from "./display";
it.each([
["OPERATION_TYPE_BUY", "buy"],
["OPERATION_TYPE_COUPON", "other"],
["OPERATION_TYPE_BUY_CARD", "buy"],
["OPERATION_TYPE_BUY_MARGIN", "buy"],
["OPERATION_TYPE_SELL", "sell"],
@@ -21,3 +24,71 @@ it.each([
])("preserves the wire operation label for %s", (type, label) => {
expect(operationLabelKey(type)).toBe(`ledger.operationType.${label}`);
});
describe("brokerageOperationGroupKey", () => {
const base: BrokerageOperation = {
account: { id: "account-1", name: "Брокерский" },
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id: "operation",
instrumentType: "bond",
instrumentUid: "UID",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: null,
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
it("uses screen day, preferred identity and exact type, not account", () => {
const first = brokerageOperationGroupKey(base);
const same = brokerageOperationGroupKey({
...base,
account: { id: "account-2", name: "ИИС" },
figi: "OTHER-FIGI",
occurredAt: "2026-08-14T11:00:00Z",
});
expect(same).toBe(first);
expect(brokerageOperationGroupKey({
...base,
type: "OPERATION_TYPE_DIVIDEND",
})).not.toBe(first);
});
it("keeps buy and sell of one instrument in different groups", () => {
expect(brokerageOperationGroupKey({ ...base, type: "OPERATION_TYPE_BUY" }))
.not.toBe(brokerageOperationGroupKey({
...base,
type: "OPERATION_TYPE_SELL",
}));
});
it("separates local midnight and joins one local day across UTC dates", () => {
const before = new Date(2026, 7, 14, 23, 59).toISOString();
const after = new Date(2026, 7, 15, 0, 1).toISOString();
const morning = new Date(2026, 7, 14, 1).toISOString();
const evening = new Date(2026, 7, 14, 23).toISOString();
expect(brokerageOperationGroupKey({ ...base, occurredAt: before }))
.not.toBe(brokerageOperationGroupKey({ ...base, occurredAt: after }));
expect(brokerageOperationGroupKey({ ...base, occurredAt: morning }))
.toBe(brokerageOperationGroupKey({ ...base, occurredAt: evening }));
});
it("falls back to FIGI and leaves missing identity ungrouped", () => {
expect(brokerageOperationGroupKey({ ...base, instrumentUid: null }))
.toBe(brokerageOperationGroupKey({ ...base, instrumentUid: "" }));
expect(brokerageOperationGroupKey({
...base,
figi: null,
instrumentUid: null,
})).toBeNull();
});
});
+33 -2
View File
@@ -1,4 +1,4 @@
import { formatMoney } from "@/shared/lib";
import { formatMoney, formatSnapshotDay } from "@/shared/lib";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
@@ -27,6 +27,19 @@ type PaymentPresentation = {
| "positive";
};
function brokerageOperationGroupKey(
operation: BrokerageOperation,
): null | string {
const identity = operation.instrumentUid || operation.figi;
if (!identity) return null;
return JSON.stringify([
formatSnapshotDay(operation.occurredAt),
identity,
operation.type,
]);
}
function entryLabelKey(operation: BrokerageOperation): string {
if (operation.category === "income")
return isOperationType(operation.type, OperationType.Coupon)
@@ -36,6 +49,16 @@ function entryLabelKey(operation: BrokerageOperation): string {
return `ledger.category.${operation.category}`;
}
function isCouponPayment(operation: BrokerageOperation): boolean {
return isOperationType(operation.type, OperationType.Coupon);
}
function isIncomePayment(operation: BrokerageOperation): boolean {
return [OperationType.Coupon, OperationType.Dividend].some(type =>
isOperationType(operation.type, type),
);
}
function isOperationType(value: unknown, expected: OperationType): boolean {
return value === expected;
}
@@ -109,4 +132,12 @@ function paymentPresentation(
};
}
export { operationLabelKey, paymentPresentation };
export {
brokerageOperationGroupKey,
entryLabelKey,
isCouponPayment,
isIncomePayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
};
@@ -1,5 +1,5 @@
import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
@@ -29,15 +29,28 @@ function useBrokerageOperationHistory(
cursor: cursors.at(-1),
});
const isInvalidCursor = isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
);
const isRecoveringInvalidCursor = cursors.length > 0 && isInvalidCursor;
const reset = () => setPagination({ cursors: [], scope });
useEffect(() => {
if (!isRecoveringInvalidCursor) return;
const timeout = setTimeout(() => {
setPagination({ cursors: [], scope });
void client.invalidateQueries({ exact: true, queryKey });
}, 0);
return () => clearTimeout(timeout);
}, [client, isRecoveringInvalidCursor, queryKey, scope]);
const retry = (retryScreen: () => unknown) => {
if (
isApiErrorCode(
apiErrorCode(operations.error),
ApiErrorCode.InvalidOperationsCursor,
)
) {
if (isInvalidCursor) {
reset();
void client.invalidateQueries({
exact: true,
@@ -49,6 +62,7 @@ function useBrokerageOperationHistory(
return {
hasPrevious: cursors.length > 0,
isRecoveringInvalidCursor,
onNext: () => {
if (operations.data?.nextCursor)
setPagination({
@@ -11,10 +11,11 @@ import {
formatSnapshotTime,
instrumentLogoUrl,
} from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { operationLabelKey, paymentPresentation } from "../model/display";
import { isIncomePayment, operationLabelKey, paymentPresentation } from "../model/display";
type LedgerEntryRowProps = {
operation: BrokerageOperation;
@@ -75,6 +76,28 @@ const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => {
}
else details = undefined;
if (isIncomePayment(operation)) {
const extraMetadata = [
instrument,
props.withDetails ? null : operation.account?.name,
].filter(Boolean).join(" · ");
return (
<PayoutCard
details={details}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(operation.logoName)}
initial={(instrument.trim()[0] ?? "?").toUpperCase()}
kind="executed"
occurredAt={formatSnapshotTime(operation.occurredAt)}
payment={operation.payment}
title={operation.description || t(operationLabelKey(operation.type))}
typeLabel={t(paymentPresentation(operation).label)}
wrap={props.wrap}
/>
);
}
return (
<EntryCard
details={details}
@@ -0,0 +1,138 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import { OperationGroup } from "./OperationGroup";
function operation(
id: string,
account: { id: string; name: string },
value: null | string,
currency = "RUB",
): BrokerageOperation {
return {
account,
accruedInterest: null,
category: "income",
commission: null,
description: "Купон",
figi: "FIGI",
id,
instrumentType: "bond",
instrumentUid: "UID",
logoName: null,
occurredAt: "2026-08-14T10:00:00Z",
payment: value === null ? null : { currency, value },
price: null,
quantity: null,
ticker: "BOND",
type: "OPERATION_TYPE_COUPON",
};
}
function renderGroup(
operations: BrokerageOperation[],
isTotalVisible = true,
) {
return render(
<MantineProvider>
<MemoryRouter>
<ul>
<OperationGroup
entries={operations.map((operation, index) => ({
key: `${operation.id}-${index}`,
operation,
}))}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
describe("OperationGroup", () => {
it("shows the received coupon and positive totals in flat account rows", () => {
renderGroup([
{ ...operation("1", { id: "broker", name: "Брокерский" }, "0.1"), description: "Выплата купонов СФО ВТБ РКС Эталон 04" },
operation("2", { id: "iis", name: "ИИС" }, "0.2"),
]);
expect(screen.getByText("Выплата купонов СФО ВТБ РКС Эталон 04")).toBeInTheDocument();
expect(screen.getByText("+0,3 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", {
name: /Выплата купонов СФО ВТБ РКС Эталон 04, Исполнено 14\.08\.2026.*, \+0,3 ₽/,
}));
expect(accounts.getByRole("listitem", { name: "Брокерский, +0,1 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByText("+0,1 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.toHaveTextContent("Купон");
expect(accounts.getByText("+0,2 ₽")).toHaveClass(/positive/);
expect(accounts.getByRole("listitem", { name: "ИИС, +0,2 ₽" }))
.not.toHaveClass("EntryGroup_accountCardRow");
expect(accounts.queryByText(/Исполнено/)).not.toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("announces the hidden total as unavailable", () => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, null),
]);
expect(screen.getByRole("list", {
name: /Выплата купонов BOND, Исполнено .*Итог недоступен/,
})).toBeInTheDocument();
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
expect(screen.getByText("—")).not.toHaveClass(/positive/);
});
it.each(["0", "0.000"])(
"hides the whole total for a zero source amount (%s)",
(value) => {
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, value),
operation("2", { id: "iis", name: "ИИС" }, "100"),
]);
expect(screen.getAllByText("+100 ₽")).toHaveLength(1);
expect(screen.getByText("Итог недоступен")).toBeInTheDocument();
},
);
it("hides the total for incomplete data but keeps compensated zero", () => {
const { unmount } = renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, "100"),
], false);
expect(screen.queryByText("+200 ₽")).not.toBeInTheDocument();
unmount();
renderGroup([
operation("1", { id: "broker", name: "Брокерский" }, "100"),
operation("2", { id: "iis", name: "ИИС" }, "-100"),
]);
expect(screen.getByText("0 ₽")).toBeInTheDocument();
expect(screen.getByText("−100 ₽")).toHaveClass(/negative/);
});
it("renders repeated ids inside one group as separate rows", () => {
renderGroup([
operation("same", { id: "broker", name: "Брокерский" }, "10"),
operation("same", { id: "iis", name: "ИИС" }, "20"),
]);
expect(screen.getByText("+30 ₽")).toBeInTheDocument();
const accounts = within(screen.getByRole("list", { name: /Выплата купонов/ }));
expect(accounts.getAllByText("Брокерский")).toHaveLength(1);
expect(accounts.getAllByText("ИИС")).toHaveLength(1);
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -0,0 +1,113 @@
import type { FC } from "react";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
formatSnapshotDay,
instrumentLogoUrl,
sumMoney,
totalText,
} from "@/shared/lib";
import { AccountCardRow, AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageOperation } from "../api/useBrokerageOperations";
import {
entryLabelKey,
isCouponPayment,
isZeroDecimal,
operationLabelKey,
paymentPresentation,
} from "../model/display";
type OperationGroupProps = {
entries: { key: string; operation: BrokerageOperation }[];
totalVisible: boolean;
};
const OperationGroup: FC<OperationGroupProps> = (props) => {
const { t } = useTranslation();
const operations = props.entries.map(entry => entry.operation);
const first = operations.at(0);
// groupItems only emits groups of at least two entries.
if (!first) return null;
const typeLabel = t(first.category === "income"
? entryLabelKey(first)
: operationLabelKey(first.type));
const isCoupon = isCouponPayment(first);
const instrument
= first.ticker ?? first.instrumentType ?? t("ledger.noInstrument");
const amounts = operations.flatMap(operation =>
operation.payment ? [operation.payment] : [],
);
const isAmountsKnown = amounts.length === operations.length
&& amounts.every(amount => !isZeroDecimal(amount.value));
const totals = isAmountsKnown && props.totalVisible
? sumMoney(amounts)
: null;
const date = formatSnapshotDay(first.occurredAt);
const description = first.description.trim();
let title = `${typeLabel} · ${instrument}`;
if (isCoupon) {
title = description && description.toLocaleLowerCase() !== "купон"
? description
: t("ledger.couponPayment", { instrument });
}
const totalsText = totalText(totals, true);
const totalTone = isCoupon && totals && !isZeroDecimal(totals.value) && !totals.value.startsWith("-")
? "positive"
: "neutral";
const accessibleName = [
title,
t("payout.executedAt", { date }),
totalsText || t("entryGroup.totalUnavailable"),
].join(", ");
return (
<EntryGroup
flat={isCoupon}
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker?.trim()[0] ?? "?").toUpperCase()}
metadata={t("payout.executedAt", { date })}
title={title}
tone={totalTone}
value={<EntryGroupTotal isSigned totals={totals} />}
valueLabel={typeLabel}
wrap
/>
)}
label={accessibleName}
>
{props.entries.map(({ key, operation }) => {
const payment = paymentPresentation(operation);
const accountRowProps = {
accountName: operation.account?.name ?? "—",
value: payment.amount,
valueLabel: t(payment.label),
};
return isCoupon
? <AccountRow key={key} {...accountRowProps} tone={payment.tone} />
: <AccountCardRow key={key} {...accountRowProps} />;
})}
</EntryGroup>
);
};
export { OperationGroup };
@@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000;
type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number];
function useBrokerageAccounts() {
return useResourceQuery({
const query = useResourceQuery({
meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(),
staleTime: 60_000,
staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS,
});
return {
...query,
};
}
export {
+11 -14
View File
@@ -4,22 +4,21 @@ import type {
BrokerageAccount,
} from "../api/useBrokerageAccounts";
const VISIBLE_CURRENCY_LIMIT = 2;
type BrokerageAccountCard = {
account: BrokerageAccount;
positionCounts: null
| { bonds: number; stocks: number };
positionCounts: null | PositionCounts;
source?: NonNullable<InvestmentResourceMeta["dependencies"]>[number];
summary:
| { remainingCurrencyCount: number; state: "available"; totals: Money[] }
| {
portfolio: NonNullable<BrokerageAccount["portfolioSummary"]>;
state: "available";
totals: NonNullable<BrokerageAccount["portfolioSummary"]>["totals"];
}
| { state: "empty" }
| { state: "unavailable" };
};
type Money = NonNullable<
BrokerageAccount["portfolioSummary"]
>["totalsByCurrency"][number];
type PositionCounts = { bonds: number; stocks: number; total: number };
function accountTypeKey(type: BrokerageAccount["type"]) {
return `accounts.types.${type}` as const;
@@ -48,9 +47,10 @@ function brokerageAccountCards(
const positionCounts = {
bonds: summary.bondPositionCount,
stocks: summary.stockPositionCount,
total: summary.totalPositionCount,
};
if (summary.totalsByCurrency.length === 0)
if (Number(summary.totals.value) === 0)
return { account, positionCounts, source, summary: { state: "empty" } };
return {
@@ -58,12 +58,9 @@ function brokerageAccountCards(
positionCounts,
source,
summary: {
remainingCurrencyCount: Math.max(
summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT,
0,
),
portfolio: summary,
state: "available",
totals: summary.totalsByCurrency.slice(0, VISIBLE_CURRENCY_LIMIT),
totals: summary.totals,
},
};
});
@@ -1,166 +1,162 @@
.list {
display: grid;
width: 100%;
margin: 0;
padding: 0;
gap: var(--mantine-spacing-sm);
list-style: none;
}
.card {
min-width: 0;
gap: var(--mantine-spacing-xl);
background: var(--mantine-color-white);
border-color: var(--mantine-color-gray-3);
box-shadow: 0 4px 16px rgb(15 23 42 / 3%);
.row {
display: grid;
grid-template-areas:
"identity balance"
"details balance"
"structure structure";
grid-template-columns: minmax(0, 1fr) minmax(0, auto);
gap: var(--mantine-spacing-sm) var(--mantine-spacing-xl);
align-items: center;
padding: var(--mantine-spacing-lg);
border: 1px solid var(--wl-color-border);
border-left: 3px solid var(--wl-color-primary);
border-radius: var(--mantine-radius-md);
background: var(--wl-color-surface);
color: inherit;
text-decoration: none;
transition:
transform 160ms ease,
box-shadow 160ms ease,
border-color 160ms ease;
transition: background-color 160ms ease, border-color 160ms ease;
}
.card:hover,
.card:focus-visible {
border-color: var(--mantine-color-blue-3);
box-shadow: 0 12px 28px rgb(15 23 42 / 8%);
transform: translateY(-3px);
.row:hover,
.row:focus-visible {
border-color: var(--wl-color-primary);
background: var(--wl-color-subtle);
}
.card:focus-visible {
outline: 2px solid var(--mantine-color-blue-6);
outline-offset: 3px;
.row:focus-visible {
outline: 2px solid var(--wl-color-primary);
outline-offset: -2px;
}
.identity {
grid-area: identity;
min-width: 0;
}
.kicker {
padding: 5px 10px;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
letter-spacing: 0.02em;
}
.accountIcon {
width: 28px;
height: 28px;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.name {
margin: 0;
font-size: var(--mantine-h3-font-size);
line-height: 1.3;
color: var(--wl-color-text);
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
overflow-wrap: anywhere;
}
.accountType {
flex-shrink: 0;
align-self: flex-start;
padding: 2px var(--mantine-spacing-xs);
border-radius: var(--mantine-radius-sm);
}
.balance {
flex: 1;
grid-area: balance;
min-width: 0;
text-align: right;
}
.summary {
font-variant-numeric: tabular-nums;
font-variant-numeric: tabular-nums lining-nums;
align-items: flex-end;
}
.currencySummary {
min-width: 0;
}
.total {
font-size: clamp(1.5rem, 2.8vw, 2.25rem);
letter-spacing: -0.035em;
line-height: 1.2;
overflow-wrap: anywhere;
}
.total + .total {
color: var(--wl-color-text);
font-size: var(--mantine-font-size-lg);
font-weight: 500;
letter-spacing: 0;
color: var(--mantine-color-gray-7);
}
.positions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--mantine-spacing-sm);
margin: 0;
}
.positions > div {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 4px var(--mantine-spacing-sm);
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-md);
}
.stocks {
background: var(--mantine-color-blue-0);
color: var(--mantine-color-blue-8);
}
.bonds {
background: var(--mantine-color-grape-0);
color: var(--mantine-color-grape-8);
}
.positions dt {
font-size: var(--mantine-font-size-sm);
font-weight: 500;
}
.positions dd {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.25;
line-height: var(--mantine-line-height-lg);
overflow-wrap: anywhere;
}
.footer {
border-top: 1px solid var(--mantine-color-gray-2);
padding-top: var(--mantine-spacing-md);
.return,
.cashValue {
font-variant-numeric: tabular-nums lining-nums;
}
.open {
display: inline-flex;
align-items: center;
.return {
justify-content: flex-end;
min-width: 0;
overflow-wrap: anywhere;
}
.cashValue {
white-space: nowrap;
}
.positive {
color: var(--wl-color-positive);
}
.negative {
color: var(--wl-color-negative);
}
.neutral {
color: var(--wl-color-muted);
}
.cash {
font-variant-numeric: tabular-nums lining-nums;
}
.details {
grid-area: details;
min-width: 0;
font-variant-numeric: tabular-nums lining-nums;
}
.structure {
grid-area: structure;
gap: var(--mantine-spacing-xs);
margin-left: auto;
color: var(--mantine-color-blue-7);
}
.arrow {
font-size: var(--mantine-font-size-lg);
transition: transform 160ms ease;
.structureBar {
display: flex;
width: 100%;
height: 6px;
overflow: hidden;
border-radius: var(--mantine-radius-xs);
background: var(--wl-color-subtle);
}
.card:hover .arrow {
transform: translateX(3px);
.structureSection {
flex: none;
height: 100%;
background: var(--category-color);
}
@media (max-width: 36em) {
.card {
@media (max-width: 47.9375em) {
.row {
grid-template-areas:
"identity"
"balance"
"structure"
"details";
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
padding: var(--mantine-spacing-md);
gap: var(--mantine-spacing-lg);
}
.positions > div {
flex-direction: column;
.balance {
text-align: left;
}
.summary {
align-items: flex-start;
padding: var(--mantine-spacing-sm);
}
}
@media (prefers-reduced-motion: reduce) {
.card,
.arrow {
transition: none;
}
.card:hover,
.card:focus-visible,
.card:hover .arrow {
transform: none;
.return {
justify-content: flex-start;
}
}
@@ -2,32 +2,84 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { MemoryRouter } from "react-router";
import { resource } from "@/test/resource";
import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
const syntheticAccount = {
id: "storybook-account",
name: "Учебный брокерский счёт",
openedOn: "2025-03-15",
portfolioSummary: {
bondPositionCount: 12,
stockPositionCount: 5,
totalsByCurrency: [
{ currency: "RUB", value: "125000.50" },
{ currency: "USD", value: "320" },
{ currency: "EUR", value: "210" },
type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
const rubReturn = {
absolute: "136900",
costBasis: "1103600.50",
partial: false,
percent: "12.40",
};
const portfolioSummary = {
bondPositionCount: 12,
cash: { currency: "RUB", value: "82500.50" },
fundPositionCount: 2,
stockPositionCount: 5,
structure: {
categories: [
{ sharePercent: "55.000000", type: "STOCK", value: "682500" },
{ sharePercent: "25.000000", type: "BOND", value: "310500" },
{ sharePercent: "10.000000", type: "OTHER", value: "124200" },
{ sharePercent: "10.000000", type: "CASH", value: "124200" },
],
hasNegativeValues: false,
totalValue: "1241400",
unvaluedCategories: [],
},
type: "BROKERAGE",
} satisfies BrokerageAccount;
totalPositionCount: 19,
totals: { currency: "RUB", value: "1240500.50" },
unrealizedReturn: rubReturn,
} satisfies PortfolioSummary;
function account(
id: string,
name: string,
summary: null | PortfolioSummary = portfolioSummary,
type: BrokerageAccount["type"] = "BROKERAGE",
): BrokerageAccount {
return {
id,
name,
openedOn: "2025-03-15",
portfolioSummary: summary,
type,
};
}
const negativeSummary: PortfolioSummary = {
...portfolioSummary,
unrealizedReturn: {
...rubReturn,
absolute: "-4200",
percent: "-3.05",
},
};
const emptySummary: PortfolioSummary = {
...portfolioSummary,
cash: { currency: "RUB", value: "0" },
structure: {
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 0,
totals: { currency: "RUB", value: "0" },
unrealizedReturn: rubReturn,
};
const meta = {
args: { accounts: brokerageAccountCards([syntheticAccount]) },
argTypes: {
accounts: { control: false },
},
args: { accounts: brokerageAccountCards([account("normal", "Основной ИИС")]) },
argTypes: { accounts: { control: false } },
component: BrokerageAccountList,
decorators: [
Story => (
@@ -40,7 +92,7 @@ const meta = {
docs: {
description: {
component:
"Карточки счетов WealthLedger со сводкой по валютам и числом наименований акций и облигаций. Используйте на экране выбора счёта; отсутствие сводки и отсутствие сумм имеют разные сообщения. Примеры содержат вымышленные данные. Ссылки меняют только локальный маршрут MemoryRouter; страница назначения в этой истории не отображается.",
"Карточки счетов: стоимость и доходность, деньги, структура и свежесть источника. Денежные и процентные значения остаются строками. Все примеры содержат вымышленные данные; ссылки работают только внутри MemoryRouter.",
},
},
},
@@ -52,182 +104,78 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Available: Story = {
name: "Сводка по нескольким валютам",
parameters: {
docs: {
description: {
story:
"Показываются первые две валюты и количество остальных. Суммы разных валют не складываются в общий итог.",
},
},
export const Normal: Story = {};
export const Negative: Story = {
args: {
accounts: brokerageAccountCards([
account("negative", "Просевший портфель", negativeSummary),
]),
},
};
export const Unavailable: Story
= {
args: {
accounts: brokerageAccountCards([
{ ...syntheticAccount, openedOn: null, portfolioSummary: null },
]),
},
name: "Сводка недоступна",
parameters: {
docs: {
description: {
story:
"Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.",
},
export const Partial: Story = {
args: {
accounts: brokerageAccountCards([account("partial", "Частичные данные", {
...portfolioSummary,
unrealizedReturn: {
...rubReturn,
partial: true,
},
},
};
})]),
},
};
export const Empty: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 0,
totalsByCurrency: [],
},
},
account("empty", "Новый пустой счёт", emptySummary),
]),
},
name: "Нет текущих сумм",
parameters: {
docs: {
description: {
story:
"Успешно полученная пустая сводка отличается от ошибки получения данных. Карточка сохраняет название, тип счёта и переход.",
},
},
};
export const Unavailable: Story = {
args: {
accounts: brokerageAccountCards([
account("unavailable", "Счёт без сводки", null),
]),
},
};
export const Stale: Story = {
args: {
accounts: brokerageAccountCards([
account("stale", "Устаревший портфель"),
], resource([], {
dependencies: [{
availability: "available",
completeness: "complete",
dataRevision: "stale-revision",
freshness: "stale",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/stale",
}],
freshness: "stale",
lastSuccessfulAt: "2026-08-14T00:00:00Z",
}).meta.dependencies),
},
};
export const LongName: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
name: "Учебный индивидуальный инвестиционный счёт для долгосрочных накоплений",
type: "INDIVIDUAL_INVESTMENT",
},
]),
},
name: "Длинное название счёта",
parameters: {
docs: {
description: {
story:
"Проверьте перенос длинного названия на узком экране и доступность всей карточки с клавиатуры.",
},
},
},
};
export const AccountGrid: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
name: "Основной",
portfolioSummary: {
bondPositionCount: 14,
stockPositionCount: 8,
totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }],
},
},
{
...syntheticAccount,
id: "storybook-iis",
name: "Долгосрочные инвестиции",
portfolioSummary: {
bondPositionCount: 6,
stockPositionCount: 0,
totalsByCurrency: [{ currency: "RUB", value: "480250" }],
},
type: "INDIVIDUAL_INVESTMENT",
},
]),
},
name: "Несколько счетов",
};
export const UnvaluedPositions: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 1,
stockPositionCount: 2,
totalsByCurrency: [],
},
},
]),
},
name: "Бумаги без текущей оценки",
};
export const LargeValues: Story = {
args: {
accounts: brokerageAccountCards([
{
...syntheticAccount,
portfolioSummary: {
bondPositionCount: 105,
stockPositionCount: 21,
totalsByCurrency: [
{ currency: "RUB", value: "1234567890123456789.50" },
{ currency: "USD", value: "-9876543210987654321.50" },
],
},
},
]),
},
name: "Длинные суммы",
};
export const IndependentFreshness: Story
= {
args: {
accounts: brokerageAccountCards(
[
syntheticAccount,
{
...syntheticAccount,
id: "waiting",
name: "Новый счёт",
portfolioSummary: null,
},
],
[
{
availability: "complete",
dataRevision: "1",
freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
stateVersion: "failed",
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "failed",
state: "failed",
},
},
{
availability: "missing",
dataRevision: "0",
freshness: "unknown",
lastSuccessfulAt: null,
resourceKey: "portfolio/waiting",
stateVersion: "waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
},
],
account(
"long",
"Очень длинное название индивидуального инвестиционного счёта, которое должно корректно переноситься на узком экране",
portfolioSummary,
"INDIVIDUAL_INVESTMENT",
),
},
name: "Независимая свежесть и ошибка сводок",
};
]),
},
};
@@ -5,122 +5,178 @@ import { describe, expect, it } from "vitest";
import { resource } from "@/test/resource";
import type { BrokerageAccount } from "../../api/useBrokerageAccounts";
import { brokerageAccountCards } from "../../model/display";
import { BrokerageAccountList } from "./BrokerageAccountList";
describe("BrokerageAccountList", () => {
it("renders account details and an account link", () => {
render(
<MantineProvider>
<MemoryRouter>
<BrokerageAccountList
accounts={brokerageAccountCards([
{
id: "account-1",
name: "Основной",
openedOn: "2024-08-13",
portfolioSummary: {
bondPositionCount: 12,
stockPositionCount: 5,
totalsByCurrency: [
{ currency: "RUB", value: "1500" },
{ currency: "USD", value: "20" },
{ currency: "EUR", value: "5" },
],
},
type: "BROKERAGE",
type PortfolioSummary = NonNullable<BrokerageAccount["portfolioSummary"]>;
},
{
id: "account-2",
name: "Недоступный",
openedOn: null,
portfolioSummary: null,
type: "BROKERAGE",
},
{
id: "account-3",
name: "Пустой",
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 2,
totalsByCurrency: [],
},
type: "BROKERAGE",
},
])}
/>
</MemoryRouter>
</MantineProvider>,
);
expect(screen.getByRole("link", { name: /Основной/ })).toHaveAttribute(
"href",
"/accounts/account-1",
);
expect(screen.getAllByText("Брокерский счет")).toHaveLength(3);
expect(screen.getByText(/^Открыт /)).toBeInTheDocument();
expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
expect(screen.getByText("20 USD")).toBeInTheDocument();
expect(screen.getByText("Еще 1 валюта")).toBeInTheDocument();
expect(screen.getByText("Сводка временно недоступна")).toBeInTheDocument();
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Открыть счет")).toHaveLength(3);
const available = within(screen.getByRole("link", { name: /Основной/ }));
expect(available.getAllByRole("definition")[0]).toHaveTextContent("5");
expect(available.getAllByRole("definition")[1]).toHaveTextContent("12");
const unvalued = within(screen.getByRole("link", { name: /Пустой/ }));
expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent("2");
expect(unvalued.getAllByRole("definition")[1]).toHaveTextContent("0");
expect(
within(screen.getByRole("link", { name: /Недоступный/ })).queryByText(
"Акции",
),
).not.toBeInTheDocument();
});
});
it("shows portfolio summary freshness independently for each account", () => {
const accounts = brokerageAccountCards(
["fresh", "stale"].map(
id =>
({
id,
name: id,
openedOn: null,
portfolioSummary: {
bondPositionCount: 0,
stockPositionCount: 1,
totalsByCurrency: [{ currency: "RUB", value: "10" }],
},
type: "BROKERAGE" as const,
}),
),
[
resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" })
.meta,
resource(null, { freshness: "stale", resourceKey: "portfolio/stale" })
.meta,
const portfolioSummary = {
bondPositionCount: 12,
cash: { currency: "RUB", value: "82500.5" },
fundPositionCount: 0,
stockPositionCount: 5,
structure: {
categories: [
{ sharePercent: "50.000000", type: "STOCK", value: "100" },
{ sharePercent: "30.000000", type: "BOND", value: "60" },
{ sharePercent: "0.000000", type: "OTHER", value: "0" },
{ sharePercent: "20.000000", type: "CASH", value: "40" },
],
);
hasNegativeValues: false,
totalValue: "200",
unvaluedCategories: [],
},
totalPositionCount: 17,
totals: { currency: "RUB", value: "123456789012345678901.995" },
unrealizedReturn: {
absolute: "136900",
costBasis: "1000000",
partial: true,
percent: "12.40",
},
} satisfies PortfolioSummary;
render(
type BrokerageAccountCardInput = Parameters<typeof BrokerageAccountList>[0][
"accounts"
];
function account(
id: string,
name: string,
summary: null | PortfolioSummary,
): BrokerageAccount {
return {
id,
name,
openedOn: null,
portfolioSummary: summary,
type: "BROKERAGE",
};
}
function renderList(accounts: BrokerageAccountCardInput) {
return render(
<MantineProvider>
<MemoryRouter>
<BrokerageAccountList accounts={accounts} />
</MemoryRouter>
</MantineProvider>,
);
expect(
within(screen.getByRole("link", { name: /stale/ })).getByText(
"Данные требуют обновления",
),
).toBeVisible();
expect(
within(screen.getByRole("link", { name: /fresh/ })).queryByText(
"Данные требуют обновления",
),
).not.toBeInTheDocument();
}
describe("BrokerageAccountList", () => {
it("renders returns, cash and structure with precise totals", () => {
const nullReturnSummary: PortfolioSummary = {
...portfolioSummary,
structure: {
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totals: { currency: "RUB", value: "5" },
unrealizedReturn: {
absolute: "0",
costBasis: "5",
partial: false,
percent: null,
},
};
renderList(brokerageAccountCards([
account("account-1", "Основной", portfolioSummary),
account("account-2", "Без доходности", nullReturnSummary),
]));
const link = screen.getByRole("link", { name: /Основной/ });
expect(link).toHaveAttribute("href", "/accounts/account-1");
expect(within(link).getByText("123 456 789 012 345 678 902,00 ₽"))
.toBeInTheDocument();
expect(within(link).getByText("+12,4 %")).toBeInTheDocument();
expect(within(link).getByText("+136 900 ₽")).toBeInTheDocument();
const nullReturnLink = screen.getByRole("link", { name: /Без доходности/ });
expect(within(nullReturnLink).getByText("—")).toBeInTheDocument();
expect(within(link).getByText("частично")).toBeInTheDocument();
expect(within(link).getByText("82 500,50 ₽")).toBeInTheDocument();
expect(within(link).getByText("17 позиций")).toBeInTheDocument();
expect(screen.getByRole("img", {
name: "Структура: Акции 50,0 %; Облигации 30,0 %; "
+ "Другие активы 0,0 %; Свободные деньги 20,0 %",
})).toBeInTheDocument();
const stockLegend = within(link).getByText(/Акции 50,0 %/);
expect(stockLegend).toHaveTextContent("● Акции 50,0 %");
// eslint-disable-next-line testing-library/no-node-access -- The legend is aria-hidden, so only DOM traversal reaches its container.
expect(stockLegend.parentElement).toHaveAttribute("aria-hidden", "true");
expect(within(link).queryByText(/Другие активы 0,0 %/))
.not.toBeInTheDocument();
expect(link).toHaveTextContent("По данным брокера");
expect(link).toHaveTextContent("Структура: Акции 100");
});
it("preserves empty, unavailable and stale states", () => {
const emptySummary: PortfolioSummary = {
...portfolioSummary,
structure: {
categories: [],
hasNegativeValues: false,
totalValue: "0",
unvaluedCategories: [],
},
totalPositionCount: 0,
totals: { currency: "RUB", value: "0" },
};
const cards = brokerageAccountCards([
account("empty", "Пустой", emptySummary),
account("unavailable", "Недоступный", null),
account("stale", "Устаревший", portfolioSummary),
account("failed", "Ошибка обновления", null),
], resource([], {
dependencies: [{
availability: "available",
completeness: "complete",
dataRevision: "stale-revision",
freshness: "stale",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/stale",
}, {
availability: "available",
completeness: "complete",
dataRevision: "failed-revision",
freshness: "stale",
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
lastSuccessfulAt: "2026-08-13T00:00:00Z",
resourceKey: "portfolio/failed",
}],
freshness: "stale",
lastSuccessfulAt: "2026-08-14T00:00:00Z",
}).meta.dependencies);
renderList(cards);
expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument();
expect(screen.getAllByText("Сводка временно недоступна"))
.toHaveLength(2);
const staleLink = screen.getByRole("link", { name: /Устаревший/ });
expect(within(staleLink).getByText("Данные требуют обновления"))
.toBeInTheDocument();
expect(within(staleLink).getByText(/Данные на/)).toBeInTheDocument();
const failedLink = screen.getByRole("link", { name: /Ошибка обновления/ });
expect(within(failedLink).getByText(
"Не удалось обновить данные. Показаны последние полученные.",
)).toBeInTheDocument();
});
});
@@ -1,11 +1,10 @@
import type { FC } from "react";
import type { CSSProperties, FC } from "react";
import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { WalletIcon } from "@wealthledger/icons";
import { Badge, Group, Stack, Text, VisuallyHidden } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { formatDate, formatMoney } from "@/shared/lib";
import { formatMoney, portfolioCategoryPresentation } from "@/shared/lib";
import { SourceSyncStatus } from "@/shared/ui";
import type { BrokerageAccountCard } from "../../model/display";
@@ -13,12 +12,171 @@ import type { BrokerageAccountCard } from "../../model/display";
import { accountTypeKey } from "../../model/display";
import classes from "./BrokerageAccountList.module.css";
const DECIMAL_PART_COUNT = 2;
const STRUCTURE_CATEGORY_ORDER = ["STOCK", "BOND", "OTHER", "CASH"] as const;
const percentFormatter = new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 1,
minimumFractionDigits: 1,
});
type AvailableSummary = Extract<
BrokerageAccountCard["summary"],
{ state: "available" }
>;
type BrokerageAccountListProps = { accounts: BrokerageAccountCard[] };
type BrokerageAccountSummaryProps = {
summary: BrokerageAccountCard["summary"];
};
type PortfolioSummary = AvailableSummary["portfolio"];
type ReturnValueProps = {
item: PortfolioSummary["unrealizedReturn"];
};
type StructureCurrency = PortfolioSummary["structure"];
function formatPercent(value: null | string) {
if (value === null) return "—";
const rounded = percentFormatter.format(Math.abs(Number(value)));
let sign = "";
if (rounded !== "0,0") sign = value.startsWith("-") ? "−" : "+";
return `${sign}${rounded}\u{A0}%`;
}
function formatStructurePercent(value: string) {
return percentFormatter.format(Number(value));
}
function isZero(value: string) {
const unsigned = value.startsWith("-") ? value.slice(1) : value;
const [integer = "", fraction = ""] = unsigned.split(".", DECIMAL_PART_COUNT);
return integer.replaceAll("0", "") === ""
&& fraction.replaceAll("0", "") === "";
}
const ReturnValue: FC<ReturnValueProps> = (props) => {
const { t } = useTranslation();
const { item } = props;
const displayedPercent = formatPercent(item.percent);
const isNegative = displayedPercent.startsWith("−");
const isPositive = displayedPercent.startsWith("+");
let toneClass = classes.neutral;
if (isNegative) toneClass = classes.negative;
else if (isPositive) toneClass = classes.positive;
return (
<Group className={classes.return} gap="xs" wrap="wrap">
<Text className={toneClass} fw={700} size="sm">
{displayedPercent}
</Text>
<Text className={toneClass} size="sm">
{formatMoney(
{ currency: "RUB", value: item.absolute },
{ withSign: true },
)}
</Text>
{item.partial && (
<Text c="dimmed" size="xs">
{t("accounts.partial")}
</Text>
)}
</Group>
);
};
type StructureBarProps = { item: StructureCurrency };
const StructureBar: FC<StructureBarProps> = (props) => {
const { t } = useTranslation();
const categories = STRUCTURE_CATEGORY_ORDER.flatMap((type) => {
const category = props.item.categories.find(item => item.type === type);
return category ? [category] : [];
});
const description = categories.map((category) => {
const label = t(portfolioCategoryPresentation[category.type].label);
const percent = category.sharePercent === null
? null
: formatStructurePercent(category.sharePercent);
return percent === null
? t("accounts.structure.incomplete", { category: label })
: t("accounts.structure.category", { category: label, percent });
}).join("; ");
const label = t("accounts.structure.label", {
value: description,
});
const legendCategories = categories.filter(category =>
category.sharePercent === null || !isZero(category.sharePercent));
return (
<Stack gap={4}>
<div
aria-label={label}
className={classes.structureBar}
role="img"
title={label}
>
{categories.map((category) => {
const presentation = portfolioCategoryPresentation[category.type];
const style = {
"--category-color": `var(--mantine-color-${presentation.color.replace(".", "-")})`,
"width": `${category.sharePercent ?? 0}%`,
} as CSSProperties;
return (
<div
aria-hidden
className={classes.structureSection}
key={category.type}
style={style}
/>
);
})}
</div>
<Group aria-hidden gap="xs" wrap="wrap">
{legendCategories.map((category) => {
const presentation = portfolioCategoryPresentation[category.type];
const categoryLabel = t(presentation.label);
const text = category.sharePercent === null
? categoryLabel
: t("accounts.structure.category", {
category: categoryLabel,
percent: formatStructurePercent(category.sharePercent),
});
return (
<Text c="dimmed" key={category.type} size="xs">
<Text c={presentation.color} component="span">●</Text>
{" "}
{text}
</Text>
);
})}
</Group>
</Stack>
);
};
const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
const { summary } = props;
const { t } = useTranslation();
@@ -36,20 +194,26 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => {
);
return (
<Stack className={classes.summary} gap={4}>
{summary.totals.map(total => (
<Text className={classes.total} fw={700} key={total.currency}>
{formatMoney(total)}
</Text>
))}
<Stack className={classes.balance} gap="xs">
<Stack className={classes.summary} gap="sm">
<Stack className={classes.currencySummary} gap={2}>
<Text className={classes.total} fw={700}>
{formatMoney(summary.totals, { withRoundedCents: true })}
</Text>
{!!summary.remainingCurrencyCount && (
<Text c="dimmed" size="sm">
{t("accounts.remainingCurrencies", {
count: summary.remainingCurrencyCount,
})}
<ReturnValue item={summary.portfolio.unrealizedReturn} />
</Stack>
</Stack>
<Group className={classes.cash} gap="xs" justify="flex-end">
<Text c="dimmed" size="xs">
{t("accounts.cash")}
</Text>
)}
<Text className={classes.cashValue} size="sm">
{formatMoney(summary.portfolio.cash, { withRoundedCents: true })}
</Text>
</Group>
</Stack>
);
};
@@ -61,19 +225,19 @@ type BrokerageAccountSourceStatusProps = {
const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
props,
) => {
const { source } = props;
const { t } = useTranslation();
const { source } = props;
const isSyncing = source?.lastAttempt.state === "running";
const isSyncing
= source?.sync.state === "running" || source?.sync.state === "queued";
const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null;
const errorText = source?.lastAttempt.state === "failed"
? t("accounts.refreshError")
: null;
return (
<>
<SourceSyncStatus
errorText={errorText}
lastAttempt={source?.lastAttempt}
lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"}
/>
@@ -87,86 +251,99 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
);
};
type AccountDescriptionProps = {
account: BrokerageAccountCard;
};
const AccountDescription: FC<AccountDescriptionProps> = (props) => {
const { t } = useTranslation();
const { account, summary } = props.account;
if (summary.state !== "available") return null;
const returns = [t("accounts.description.return", {
absolute: formatMoney(
{ currency: "RUB", value: summary.portfolio.unrealizedReturn.absolute },
{ withSign: true },
),
percent: formatPercent(summary.portfolio.unrealizedReturn.percent),
})];
const structures = (() => {
const categories = summary.portfolio.structure.categories.map(category =>
t("accounts.description.structureCategory", {
category: t(portfolioCategoryPresentation[category.type].label),
value: formatMoney(
{ currency: "RUB", value: category.value },
{ withRoundedCents: true },
),
}),
).join(", ");
return t("accounts.description.structure", { value: categories });
})();
const hasPartialReturn = summary.portfolio.unrealizedReturn.partial;
const details = [returns, structures].join(". ");
return (
<VisuallyHidden>
{t("accounts.description.start", {
name: account.name,
type: t(accountTypeKey(account.type)),
})}
{t("accounts.description.currentValue", {
value: formatMoney(summary.portfolio.totals),
})}
{details ? `${details}. ` : ""}
{hasPartialReturn ? t("accounts.brokerDescription") : ""}
</VisuallyHidden>
);
};
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
const { accounts } = props;
const { t } = useTranslation();
return (
<SimpleGrid className={classes.list} cols={1} spacing="lg">
{accounts.map(({ account, positionCounts, source, summary }) => (
<Card
className={classes.card}
component={Link}
key={account.id}
padding="xl"
radius="lg"
to={`/accounts/${account.id}`}
withBorder
>
<Stack className={classes.identity} gap="sm">
<Group gap="sm" justify="space-between" wrap="nowrap">
<Text className={classes.kicker} fw={700} size="xs">
<ul className={classes.list}>
{props.accounts.map(({ account, positionCounts, source, summary }) => (
<li key={account.id}>
<Link className={classes.row} to={`/accounts/${account.id}`}>
<Group className={classes.identity} wrap="nowrap">
<h2 className={classes.name}>{account.name}</h2>
<Badge className={classes.accountType} variant="light">
{t(accountTypeKey(account.type))}
</Text>
<WalletIcon className={classes.accountIcon} />
</Badge>
</Group>
<Text className={classes.name} component="h2" fw={700}>
{account.name}
</Text>
</Stack>
<Stack className={classes.balance} gap="xs">
<Text c="dimmed" size="sm">
{t("accounts.currentValue")}
</Text>
<BrokerageAccountSummary summary={summary} />
<BrokerageAccountSourceStatus source={source} />
</Stack>
<Stack className={classes.details} gap={2}>
{positionCounts && (
<Text c="dimmed" size="xs">
{t("accounts.positions", {
count: positionCounts.total,
})}
</Text>
)}
{!!positionCounts && (
<dl className={classes.positions}>
<div className={classes.stocks}>
<dt>{t("accounts.stocks")}</dt>
<BrokerageAccountSourceStatus source={source} />
</Stack>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.stocks)}
</dd>
</div>
<div className={classes.bonds}>
<dt>{t("accounts.bonds")}</dt>
<dd>
{new Intl.NumberFormat("ru-RU").format(positionCounts.bonds)}
</dd>
</div>
</dl>
)}
<Group className={classes.footer} gap="sm" justify="space-between">
{!!account.openedOn && (
<Text c="dimmed" size="xs">
{t("accounts.openedOn", {
date: formatDate(account.openedOn),
})}
</Text>
{summary.state === "available" && (
<Stack className={classes.structure} gap={4}>
<StructureBar item={summary.portfolio.structure} />
</Stack>
)}
<Text className={classes.open} component="span" fw={600} size="sm">
{t("accounts.openAccount")}
<span aria-hidden="true" className={classes.arrow}>
→
</span>
</Text>
</Group>
</Card>
<AccountDescription
account={{ account, positionCounts, source, summary }}
/>
</Link>
</li>
))}
</SimpleGrid>
</ul>
);
};
+2 -1
View File
@@ -2,8 +2,9 @@ export {
type BrokerageEvent,
useBrokerageEvents,
} from "./api/useBrokerageEvents";
export { brokerageEventKey } from "./model/event-key";
export { brokerageEventGroupKey, brokerageEventKey } from "./model/event-key";
export { BrokerageEventCard } from "./ui/BrokerageEventCard";
export { BrokerageEventGroup } from "./ui/BrokerageEventGroup";
export {
BrokerageEventsPreview,
type BrokerageEventsPreviewState,
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { brokerageEventGroupKey } from "./event-key";
const base: BrokerageEvent = {
account: { id: "account-1", name: "Брокерский" },
date: "2026-08-20",
dateKind: "event",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: { currency: "RUB", value: "100" },
ticker: "OFZ",
type: "coupon",
};
describe("brokerageEventGroupKey", () => {
it("uses date, preferred identity, exact type and dateKind", () => {
const key = brokerageEventGroupKey(base);
expect(brokerageEventGroupKey({
...base,
account: { id: "account-2", name: "ИИС" },
figi: "OTHER-FIGI",
})).toBe(key);
expect(brokerageEventGroupKey({ ...base, date: "2026-08-21" }))
.not.toBe(key);
expect(brokerageEventGroupKey({ ...base, dateKind: "registry" }))
.not.toBe(key);
expect(brokerageEventGroupKey({ ...base, type: "dividend" }))
.not.toBe(key);
});
it("falls back through FIGI and ISIN and rejects missing identity", () => {
expect(brokerageEventGroupKey({ ...base, instrumentUid: null }))
.toBe(brokerageEventGroupKey({ ...base, instrumentUid: "" }));
expect(brokerageEventGroupKey({
...base,
figi: null,
instrumentUid: null,
})).toBe(brokerageEventGroupKey({
...base,
figi: "",
instrumentUid: null,
}));
expect(brokerageEventGroupKey({
...base,
figi: null,
instrumentUid: null,
isin: null,
})).toBeNull();
});
});
@@ -1,5 +1,13 @@
import type { BrokerageEvent } from "../api/useBrokerageEvents";
function brokerageEventGroupKey(event: BrokerageEvent): null | string {
const identity = event.instrumentUid || event.figi || event.isin;
return identity
? JSON.stringify([event.date, identity, event.type, event.dateKind])
: null;
}
function brokerageEventKey(event: BrokerageEvent) {
return JSON.stringify([
event.account?.id, event.type, event.date, event.instrumentUid,
@@ -7,4 +15,4 @@ function brokerageEventKey(event: BrokerageEvent) {
]);
}
export { brokerageEventKey };
export { brokerageEventGroupKey, brokerageEventKey };
@@ -4,6 +4,7 @@ import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib";
import { PayoutCard } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
@@ -15,6 +16,27 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
const date = formatDate(event.date);
if (event.type === "coupon" || event.type === "dividend") {
const title = [t(`events.type.${event.type}`), event.displayName].join(" · ");
const extraMetadata = [event.isin, event.account?.name]
.filter(Boolean)
.join(" · ");
return (
<PayoutCard
date={date}
dateKind={event.dateKind}
extraMetadata={extraMetadata}
imageSrc={instrumentLogoUrl(event.logoName)}
initial={(event.ticker.trim()[0] ?? "?").toUpperCase()}
kind="forecast"
projectedAmount={event.projectedAmount}
title={title}
/>
);
}
const metadata = [
date,
event.dateKind === "registry"
@@ -40,7 +62,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => {
title={[t(`events.type.${event.type}`), event.displayName].join(" · ")}
tone="neutral"
value={projectedAmount}
valueLabel={t("events.projectedAmount")}
valueLabel={t("payout.forecast")}
/>
);
};
@@ -0,0 +1,122 @@
import { MantineProvider } from "@mantine/core";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { BrokerageEventGroup } from "./BrokerageEventGroup";
function event(
account: { id: string; name: string },
value: null | string,
currency = "RUB",
): BrokerageEvent {
return {
account,
date: "2026-08-20",
dateKind: "registry",
displayName: "ОФЗ 26238",
figi: "FIGI",
instrumentUid: "UID",
isin: "RU000A0JX0J2",
logoName: null,
projectedAmount: value === null ? null : { currency, value },
ticker: "OFZ",
type: "coupon",
};
}
function renderGroup(
events: [BrokerageEvent, BrokerageEvent],
isTotalVisible = true,
) {
return render(
<MantineProvider>
<MemoryRouter>
<ul>
<BrokerageEventGroup
events={events}
totalVisible={isTotalVisible}
/>
</ul>
</MemoryRouter>
</MantineProvider>,
);
}
describe("BrokerageEventGroup", () => {
it("shows an unsigned forecast total and every account forecast", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "100.5"),
event({ id: "iis", name: "ИИС" }, "49.5"),
]);
expect(screen.getByText("Купон · ОФЗ 26238")).toBeInTheDocument();
expect(screen.getByText("150 ₽")).toBeInTheDocument();
expect(screen.getAllByText("Прогноз").length).toBeGreaterThanOrEqual(3);
const accounts = within(screen.getByRole("list", {
name: /Купон · ОФЗ 26238, 20\.08\.2026 · Дата реестра, 150 ₽/,
}));
expect(accounts.getByText("Брокерский")).toBeInTheDocument();
expect(accounts.getByText("ИИС")).toBeInTheDocument();
expect(accounts.getByText("100,5 ₽")).toBeInTheDocument();
expect(accounts.getByText("49,5 ₽")).toBeInTheDocument();
expect(accounts.queryByRole("link")).not.toBeInTheDocument();
});
it("announces an unknown total as unavailable", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, null),
event({ id: "iis", name: "ИИС" }, "49.5"),
]);
expect(screen.getByRole("list", {
name: /Купон · ОФЗ 26238.*Итог недоступен/,
})).toBeInTheDocument();
expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument();
});
it("hides the total for incomplete data", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "100.5"),
event({ id: "iis", name: "ИИС" }, "49.5"),
], false);
expect(screen.queryByText("150 ₽")).not.toBeInTheDocument();
});
it("allows zero source forecasts to produce a visible zero total", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "0"),
event({ id: "iis", name: "ИИС" }, "0.00"),
]);
expect(screen.getAllByText("0 ₽")).toHaveLength(2);
expect(screen.getByText("0,00 ₽")).toBeInTheDocument();
});
it("sums forecast totals exactly", () => {
renderGroup([
event({ id: "broker", name: "Брокерский" }, "10"),
event({ id: "iis", name: "ИИС" }, "200"),
]);
expect(screen.getByRole("list", { name: /210 ₽/ }))
.toBeInTheDocument();
});
it("shows an offer group without a total", () => {
renderGroup([
{ ...event({ id: "broker", name: "Брокерский" }, null), type: "offer" },
{ ...event({ id: "iis", name: "ИИС" }, null), type: "offer" },
]);
const accounts = within(screen.getByRole("list", {
name: /Итог недоступен/,
}));
expect(accounts.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -0,0 +1,87 @@
import type { FC } from "react";
import { EntryCard } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
formatDate,
formatMoney,
instrumentLogoUrl,
sumMoney,
totalText,
} from "@/shared/lib";
import { AccountRow, EntryGroup, EntryGroupTotal } from "@/shared/ui";
import type { BrokerageEvent } from "../api/useBrokerageEvents";
import { brokerageEventKey } from "../model/event-key";
type BrokerageEventGroupProps = {
events: [BrokerageEvent, BrokerageEvent, ...BrokerageEvent[]];
totalVisible: boolean;
};
const BrokerageEventGroup: FC<BrokerageEventGroupProps> = (props) => {
const { t } = useTranslation();
const [first] = props.events;
const typeLabel = t(`events.type.${first.type}`);
const amounts = props.events.flatMap(event =>
event.projectedAmount ? [event.projectedAmount] : [],
);
const totals = props.totalVisible && amounts.length === props.events.length
? sumMoney(amounts)
: null;
const date = formatDate(first.date);
const metadata = [
date,
first.dateKind === "registry" ? t("events.dateKind.registry") : null,
].filter(Boolean).join(" · ");
const title = `${typeLabel} · ${first.displayName}`;
const totalsText = totalText(totals, false);
const accessibleName = [
title,
metadata,
totalsText || t("entryGroup.totalUnavailable"),
].join(", ");
return (
<EntryGroup
header={(
<EntryCard
imageSrc={instrumentLogoUrl(first.logoName)}
initial={(first.ticker.trim()[0] ?? "?").toUpperCase()}
metadata={metadata}
title={title}
tone="neutral"
value={<EntryGroupTotal totals={totals} />}
valueLabel={t("payout.forecast")}
wrap
/>
)}
label={accessibleName}
>
{props.events.map((event) => {
const value = event.projectedAmount
? formatMoney(event.projectedAmount)
: t("events.noAmount");
return (
<AccountRow
accountName={event.account?.name ?? "—"}
key={brokerageEventKey(event)}
value={value}
valueLabel={t("payout.forecast")}
/>
);
})}
</EntryGroup>
);
};
export { BrokerageEventGroup };
@@ -1,29 +1,30 @@
import type { FC } from "react";
import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core";
import { Button, Skeleton, Stack, Text } from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { SourceSyncStatus } from "@/shared/ui";
import { groupItems } from "@/shared/lib";
import type { BrokerageEvent } from "../../api/useBrokerageEvents";
import { brokerageEventKey } from "../../model/event-key";
import { brokerageEventGroupKey, brokerageEventKey } from "../../model/event-key";
import { BrokerageEventCard } from "../BrokerageEventCard";
import { BrokerageEventGroup } from "../BrokerageEventGroup";
type BrokerageEventsPreviewProps = {
allAccounts?: boolean;
events: BrokerageEventsPreviewState;
moreLabel?: string;
};
type BrokerageEventsPreviewState
= | {
items: BrokerageEvent[];
lastSuccessfulAt?: null | string;
refreshError?: boolean;
stale?: boolean;
state: "loaded" | "partial";
to: string;
totalVisible?: boolean;
}
| { state: "empty"
| "error"
@@ -33,43 +34,49 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => {
const { events } = props;
const { t } = useTranslation();
const errorText
= events.state === "loaded" && events.refreshError
? t("accounts.refreshError")
: null;
return (
<Stack gap="sm">
{events.state === "loaded" && (
<SourceSyncStatus
errorText={errorText}
lastSuccessfulAt={events.lastSuccessfulAt}
stale={events.stale}
/>
)}
{events.state === "loading" && (
<LoadingSkeleton label={t("events.recentLoading")}>
{["first", "second", "third"].map(placeholder => <Skeleton height={48} key={placeholder} radius="sm" />)}
</LoadingSkeleton>
)}
{events.state === "error" && <Alert color="red">{t("events.recentError")}</Alert>}
{events.state === "error" && <Text c="dimmed">{t("states.unavailableText")}</Text>}
{events.state === "empty" && <Text c="dimmed">{t("events.recentEmpty")}</Text>}
{events.state === "partial" && <Text role="status">{t("investment.partial")}</Text>}
{(events.state === "loaded" || events.state === "partial")
&& events.items.map(event => (
<BrokerageEventCard
event={event}
key={brokerageEventKey(event)}
/>
))}
{(events.state === "loaded" || events.state === "partial") && (
<Stack
component="ul"
gap="sm"
m={0}
p={0}
style={{ listStyle: "none" }}
>
{groupItems(
events.items,
props.allAccounts ? brokerageEventGroupKey : () => null,
).map(entry => entry.type === "group"
? (
<BrokerageEventGroup
events={entry.items}
key={entry.key}
totalVisible={events.totalVisible === true}
/>
)
: (
<li key={brokerageEventKey(entry.item)}>
<BrokerageEventCard event={entry.item} />
</li>
))}
</Stack>
)}
<Button component={Link} to={events.to} variant="subtle">
{t("events.open")}
{props.moreLabel ?? t("events.open")}
</Button>
</Stack>
);
-4
View File
@@ -4,9 +4,6 @@ type IncomeAnalytics = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
type IncomeAnalyticsMeta
= paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["meta"];
type IncomeSummary = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
@@ -47,7 +44,6 @@ function useIncomeSummary(accountId?: string) {
export {
type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary,
useIncomeAnalytics,
useIncomeSummary,
-1
View File
@@ -1,6 +1,5 @@
export {
type IncomeAnalytics,
type IncomeAnalyticsMeta,
type IncomeSummary,
useIncomeAnalytics,
useIncomeSummary,
@@ -0,0 +1,20 @@
import { apiQueryClient, type paths, useAuthenticatedQuery } from "@/shared/api";
type Performance = NonNullable<
paths["/api/v1/brokerage-accounts/{accountId}/performance"]["get"]["responses"][200]["content"]["application/json"]["data"]
>;
function usePerformance(accountId: string) {
return useAuthenticatedQuery({
meta: { accessGroup: "brokerage" },
...apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/performance",
{ params: { path: { accountId } } },
),
enabled: Boolean(accountId),
staleTime: 30_000,
});
}
export { type Performance, usePerformance };
+1
View File
@@ -0,0 +1 @@
export { type Performance, usePerformance } from "./api/usePerformance";
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Portfolio } from "../api/usePortfolio";
const PERCENT_SCALE = 100;
type Money = Portfolio["totalsByCurrency"][number];
type Money = Portfolio["totals"];
function formatShare(
numerator: Money | null,
+2 -16
View File
@@ -6,10 +6,6 @@ import {
type StockCandles = NonNullable<paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const MIN_STOCK_SEARCH_LENGTH = 2;
type StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"]
>;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) {
@@ -96,18 +94,7 @@ function useStockQuote(uid: string) {
});
}
function useStockSearch(query: string) {
return useResourceQuery({
...apiQueryClient.queryOptions("get", "/api/v1/stocks", {
params: { query: { query } },
}),
enabled: query.length >= MIN_STOCK_SEARCH_LENGTH,
staleTime: STOCK_STALE_TIME_MS,
});
}
export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends,
type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
};
-2
View File
@@ -1,5 +1,4 @@
export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles,
type StockDividends,
type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends,
useStockPosition,
useStockQuote,
useStockSearch,
} from "./api/queries";
+5
View File
@@ -7,7 +7,12 @@ type AuthContextValue = {
logout: () => Promise<void>;
ready: boolean;
register: (email: string, password: string) => Promise<void>;
retry: () => void;
signedOut: boolean;
/**
Refresh failed with a network error or 5xx; the refresh cookie may still be valid.
*/
unavailable: boolean;
user: undefined | User;
};
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { loginSchema, registerSchema } from "./auth.schema";
const email = "user@example.com";
function canLogIn(password: string) {
return loginSchema.safeParse({ email, password }).success;
}
function canRegister(password: string, confirmation = password) {
return registerSchema.safeParse({
email,
password,
passwordConfirmation: confirmation,
}).success;
}
// Register policy: 8..72 printable ASCII (bcrypt reads 72 bytes, so non-ASCII could be
// truncated). Login stays lenient so accounts created under the old policy still sign in.
describe("auth password policy", () => {
it.each([
["7 chars", "a".repeat(7), false],
["8 chars", "a".repeat(8), true],
["72 chars", "a".repeat(72), true],
["73 chars", "a".repeat(73), false],
["printable symbols and space", "Aa1 !~\"#$%&'()*+,-./:;<=>?@", true],
["Cyrillic, 8+ chars", "пароль-пароль", false],
["emoji", "passw😀rd", false],
["tab", "pass\tword1", false],
["newline", "password1\n", false],
["DEL", "password\u{7F}1", false],
])("register: %s", (_, password, accepted) => {
expect(canRegister(password)).toBe(accepted);
});
it("register compares the raw password with its confirmation", () => {
const padded = " spaced pass ";
expect(canRegister(padded)).toBe(true);
expect(canRegister(padded, padded.trim())).toBe(false);
});
it.each([
["legacy Cyrillic password", "пароль-пароль", true],
["short legacy password", "short", true],
["72 bytes", "a".repeat(72), true],
["73 bytes", "a".repeat(73), false],
["36 Cyrillic letters = 72 bytes", "я".repeat(36), true],
["37 Cyrillic letters = 74 bytes", "я".repeat(37), false],
["empty", "", false],
])("login: %s", (_, password, accepted) => {
expect(canLogIn(password)).toBe(accepted);
});
});
+38 -14
View File
@@ -7,8 +7,10 @@ const EMAIL_PART_COUNT = 2;
const ASCII_SPACE = 32;
const ASCII_DELETE = 127;
const MAX_EMAIL_LENGTH = 254;
const MIN_PASSWORD_BYTES = 12;
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72;
// Printable ASCII keeps characters == bytes, within the bcrypt 72-byte limit.
const ASCII_TILDE = 126;
function isEmailCharacterInvalid(character: string): boolean {
const codePoint = character.codePointAt(0);
@@ -20,6 +22,14 @@ function isEmailCharacterInvalid(character: string): boolean {
);
}
function isPrintableAscii(value: string): boolean {
return value.split("").every((character) => {
const code = character.codePointAt(0) ?? 0;
return code >= ASCII_SPACE && code <= ASCII_TILDE;
});
}
const email = z
.string()
.trim()
@@ -33,28 +43,42 @@ const email = z
{ error: validationMessage("auth.validation.invalidEmail") },
);
const password = z
// Login stays lenient: accounts created under an older policy must still sign in.
const loginPassword = z
.string()
.min(1)
.refine(
value =>
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES
&& new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.invalidPasswordLength") },
value => new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.passwordTooLong") },
);
const registerPassword = z
.string()
.min(MIN_PASSWORD_LENGTH, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.max(MAX_PASSWORD_BYTES, {
error: validationMessage("auth.validation.invalidPasswordLength"),
})
.refine(isPrintableAscii, {
error: validationMessage("auth.validation.invalidPasswordLength"),
});
const loginSchema = z.object({
email,
password,
password: loginPassword,
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
value => value.password === value.passwordConfirmation,
{
error: validationMessage("auth.validation.passwordsDoNotMatch"),
path: ["passwordConfirmation"],
},
);
const registerSchema = loginSchema
.extend({ password: registerPassword })
.refine(
value => value.password === value.passwordConfirmation,
{
error: validationMessage("auth.validation.passwordsDoNotMatch"),
path: ["passwordConfirmation"],
},
);
type AuthFormValues = z.input<typeof loginSchema>;
+73 -8
View File
@@ -3,8 +3,9 @@ import type { FC, PropsWithChildren } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import * as sharedApi from "@/shared/api";
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
@@ -92,8 +93,10 @@ describe("AuthProvider", () => {
);
});
it("settles unauthenticated and clears cached data when cold-start refresh has a transport error", async () => {
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
it("settles unauthenticated and clears cached data when cold-start refresh is rejected with 401", async () => {
server.use(http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }),
));
const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
@@ -103,12 +106,54 @@ describe("AuthProvider", () => {
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeUndefined();
expect(result.current.unavailable).toBe(false);
expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
it("keeps the original protected 401 when serialized refresh has a transport error", async () => {
it.each([
["a transport error", () => HttpResponse.error()],
["a 502 from the proxy", () => new HttpResponse(null, { status: 502 })],
["a 503 problem", () => HttpResponse.json({ code: "INTERNAL_SERVER_ERROR" }, { status: 503 })],
])("settles as unavailable and keeps cached data when cold-start refresh gets %s", async (_name, failure) => {
server.use(http.post("*/api/v1/auth/refresh", failure));
const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "kept" });
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeUndefined();
expect(result.current.unavailable).toBe(true);
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "kept" });
});
it("restores the session when retry succeeds after an outage", async () => {
let isOutage = true;
server.use(http.post("*/api/v1/auth/refresh", () =>
isOutage
? new HttpResponse(null, { status: 502 })
: HttpResponse.json({
accessToken: "restored-token",
user: { email: "user@example.com", id: "user-1" },
}),
));
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.unavailable).toBe(true));
isOutage = false;
act(() => result.current.retry());
await waitFor(() => expect(result.current.user?.id).toBe("user-1"));
expect(result.current.unavailable).toBe(false);
expect(await requestAuthorization()).toBe("Bearer restored-token");
});
it("keeps the original protected 401 and the session when serialized refresh has a transport error", async () => {
let refreshCalls = 0;
server.use(
http.post("*/api/v1/auth/refresh", () => {
@@ -137,10 +182,11 @@ describe("AuthProvider", () => {
const response = await apiClient.GET("/api/v1/brokerage-accounts");
expect(response.response.status).toBe(401);
await waitFor(() => expect(result.current.user).toBeUndefined());
expect(await requestAuthorization()).toBeNull();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
// An unreachable server does not prove the session ended; the next 401 refreshes again.
await waitFor(() => expect(result.current.unavailable).toBe(true));
expect(result.current.user?.id).toBe("user-1");
expect(queryClient.getQueryData(["cached-query"])).toEqual({ value: "stale" });
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "bank-card-1" }]);
});
it("clears connection status and financial cache independently on logout", async () => {
@@ -353,4 +399,23 @@ describe("AuthProvider", () => {
expect(result.current.cards.data?.[0]?.id).toBe("b-card"),
);
});
it("registers auth handlers once until unmount", async () => {
const configure = vi.spyOn(sharedApi, "configureAuthClient");
onTestFinished(() => configure.mockRestore());
const { rerender, result, unmount } = renderHook(useAuth, {
wrapper: createAuthWrapper(createTestQueryClient()),
});
await waitFor(() => expect(result.current.ready).toBe(true));
const registrations = configure.mock.calls.length;
rerender();
expect(configure).toHaveBeenCalledTimes(registrations);
unmount();
expect(configure).toHaveBeenCalledTimes(registrations + 1);
expect(configure).toHaveBeenLastCalledWith();
});
});
+27 -15
View File
@@ -5,6 +5,9 @@ import { apiClient, configureAuthClient, type paths } from "@/shared/api";
import { type User } from "./auth-context";
// A 5xx or network error means the server is unreachable, not that the session ended.
const HTTP_SERVER_ERROR = 500;
type Authentication
= paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
@@ -32,20 +35,18 @@ function useAuthProviderVm() {
const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
const [unavailable, setUnavailable] = useState(false);
const unavailableRef = useRef(false);
const generationRef = useRef(0);
const userRef = useRef<undefined | User>(undefined);
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
// Cookie-writing requests share ordering, including across tabs with Web Locks.
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
const next = authQueueRef.current.then(async () =>
"locks" in navigator
? navigator.locks.request("wealth-ledger-refresh", run)
: run(),
);
const next = authQueueRef.current.then(run);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
authQueueRef.current = next.catch(() => {});
@@ -53,12 +54,18 @@ function useAuthProviderVm() {
return next;
}, []);
const markUnavailable = useCallback((isUnavailable: boolean) => {
unavailableRef.current = isUnavailable;
setUnavailable(isUnavailable);
}, []);
const applyAuthentication = useCallback(
(authentication: Authentication) => {
const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false);
markUnavailable(false);
accessTokenRef.current = authentication.accessToken;
if (changedIdentity) {
@@ -71,16 +78,17 @@ function useAuthProviderVm() {
userRef.current = authentication.user;
setUser(authentication.user);
},
[queryClient],
[markUnavailable, queryClient],
);
const clearSession = useCallback(() => {
markUnavailable(false);
generationRef.current += 1;
userRef.current = undefined;
setUser(undefined);
accessTokenRef.current = undefined;
queryClient.clear();
}, [queryClient]);
}, [markUnavailable, queryClient]);
const refresh = useCallback((): Promise<boolean> => {
if (refreshPromiseRef.current) return refreshPromiseRef.current;
@@ -90,11 +98,12 @@ function useAuthProviderVm() {
if (started !== generationRef.current) return false;
try {
const { data } = await apiClient.POST("/api/v1/auth/refresh");
const { data, response } = await apiClient.POST("/api/v1/auth/refresh");
if (started !== generationRef.current) return false;
if (!data) {
clearSession();
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
else clearSession();
return false;
}
@@ -104,20 +113,19 @@ function useAuthProviderVm() {
return true;
}
catch {
if (started === generationRef.current) clearSession();
if (started === generationRef.current) markUnavailable(true);
return false;
}
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
}).finally(() => {
if (refreshPromiseRef.current === pending)
refreshPromiseRef.current = undefined;
refreshPromiseRef.current = undefined;
});
refreshPromiseRef.current = pending;
return pending;
}, [applyAuthentication, clearSession, serialize]);
}, [applyAuthentication, clearSession, markUnavailable, serialize]);
useEffect(() => {
void refresh().finally(() => setReady(true));
@@ -151,8 +159,10 @@ function useAuthProviderVm() {
clear: clearSession,
generation: () => generationRef.current,
refresh,
unavailable: () => unavailableRef.current,
});
});
// `user` tracks access-token changes, which configureAuthClient publishes as session state.
}, [clearSession, refresh, user]);
useEffect(() => () => configureAuthClient(), []);
const authContextValue = {
@@ -162,7 +172,9 @@ function useAuthProviderVm() {
ready,
register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password),
retry: () => void refresh(),
signedOut,
unavailable,
user,
};
@@ -30,4 +30,79 @@ describe("AuthForm", () => {
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument();
});
it.each([
["register", "Регистрация", "Уже есть аккаунт? Войти"],
["login", "Вход", "Нет аккаунта? Зарегистрироваться"],
])("blocks the switch-auth link while %s is submitting", async (_mode, submitLabel, linkLabel) => {
const { promise, resolve } = Promise.withResolvers<void>();
const onSubmit = vi.fn(() => promise);
render(
<MantineProvider>
<MemoryRouter>
<AuthForm
onSubmit={onSubmit}
register={_mode === "register"}
/>
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" },
});
if (_mode === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
target: { value: "a secure password" },
});
}
fireEvent.click(screen.getByRole("button", { name: submitLabel }));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
expect(screen.queryByRole("link", { name: linkLabel })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: linkLabel })).toBeDisabled();
resolve();
await screen.findByRole("link", { name: linkLabel });
});
// Register policy is 8..72 printable ASCII; login accepts legacy passwords.
it.each([
["register", "Регистрация", "пароль-пароль", false],
["register", "Регистрация", "passw😀rd", false],
["register", "Регистрация", "a".repeat(7), false],
["register", "Регистрация", "a".repeat(8), true],
["register", "Регистрация", "a".repeat(72), true],
["register", "Регистрация", "a".repeat(73), false],
["login", "Вход", "пароль-пароль", true],
])("%s submits password %j: %s", async (mode, submitLabel, password, submitted) => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
<MantineProvider>
<MemoryRouter>
<AuthForm onSubmit={onSubmit} register={mode === "register"} />
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "user@example.com" } });
fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: password } });
if (mode === "register") {
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { target: { value: password } });
}
fireEvent.click(screen.getByRole("button", { name: submitLabel }));
await waitFor(() => expect(
screen.queryByText(/Пароль: от 8 до 72 символов/) === null,
).toBe(submitted));
expect(onSubmit).toHaveBeenCalledTimes(submitted ? 1 : 0);
});
});
+21 -5
View File
@@ -1,4 +1,4 @@
import type { FC } from "react";
import type { FC, ReactNode } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, Fieldset, PasswordInput, Stack, TextInput } from "@mantine/core";
@@ -15,7 +15,7 @@ import {
type AuthFormProps = {
onSubmit: (email: string, password: string) => Promise<void>;
register?: true;
register?: boolean;
};
const AuthForm: FC<AuthFormProps> = (props) => {
@@ -45,6 +45,24 @@ const AuthForm: FC<AuthFormProps> = (props) => {
});
const to = register ? "/login" : "/register";
const switchLabel = t(register ? "auth.haveAccount" : "auth.noAccount");
let switchLink: ReactNode;
if (isSubmitting) {
switchLink = (
<Button aria-disabled disabled variant="subtle">
{switchLabel}
</Button>
);
}
else {
switchLink = (
<Button component={Link} to={to} variant="subtle">
{switchLabel}
</Button>
);
}
return (
<FormProvider {...methods}>
@@ -77,9 +95,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
{t(register ? "auth.register" : "auth.login")}
</Button>
<Button component={Link} to={to} variant="subtle">
{t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button>
{switchLink}
</Stack>
</Fieldset>
</form>
@@ -5,57 +5,69 @@ import { resource } from "@/test/resource";
import { section } from "./section";
describe("instrument source and transport presentation", () => {
it.each(["missing", "partial", "complete"] as const)(
it.each([
["initial-loading", "incomplete"],
["available", "complete"],
] as const)(
"keeps %s separate from successful transport",
(availability) => {
const data = availability === "missing" ? undefined : { rows: [] };
(availability, completeness) => {
const data = availability === "initial-loading" ? undefined : { rows: [] };
const state = section({
data,
isError: false,
isFetching: false,
meta: resource(data, { availability }).meta,
refetch: vi.fn(),
meta: resource(data, { availability, completeness }).meta,
});
expect(state.loading).toBe(availability === "missing");
expect(state.partial).toBe(availability === "partial");
expect(state.hasData).toBe(availability !== "missing");
expect(state.loading).toBe(availability === "initial-loading");
expect(state.partial).toBe(completeness === "incomplete");
expect(state.hasData).toBe(availability !== "initial-loading");
},
);
it("preserves a full fresh source and timestamp when force fails, while offering scoped retry", () => {
const retry = vi.fn();
const refetch = vi.fn();
it("preserves empty successful data instead of treating it as missing", () => {
const state = section({
data: [],
isError: false,
meta: resource([]).meta,
});
expect(state).toMatchObject({
error: false,
hasData: true,
loading: false,
});
});
it("preserves a full source when force fails", () => {
const meta = resource([], {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}).meta;
const state = section(
{ data: [], isError: false, isFetching: false, meta, refetch },
retry,
);
const state = section({ data: [], isError: false, meta });
expect(state).toMatchObject({
error: true,
hasData: true,
lastSuccessfulAt: meta.lastSuccessfulAt,
loading: false,
refreshing: false,
});
state.retry();
expect(retry).toHaveBeenCalledOnce();
expect(refetch).not.toHaveBeenCalled();
});
it("shows source failure without a false loading or empty result", () => {
const state = section({
data: undefined,
isError: false,
isFetching: false,
meta: resource(null, {
sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" },
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
}).meta,
refetch: vi.fn(),
});
expect(state).toMatchObject({
@@ -64,4 +76,29 @@ describe("instrument source and transport presentation", () => {
loading: false,
});
});
it("carries the retry of an exhausted read and only of that read", () => {
const refetch = vi.fn(() => Promise.resolve());
const state = section({
data: { rows: [] },
error: { code: "TBANK_UNAVAILABLE", status: 503 },
isError: true,
isFetching: false,
refetch,
});
expect(state.readRetry?.exhausted).toBe(true);
state.readRetry?.onRetry();
expect(refetch).toHaveBeenCalledOnce();
});
it("offers no retry for access loss or a healthy read", () => {
const base = { data: undefined, isFetching: false, refetch: vi.fn() };
expect(section({ ...base, error: { code: "NOT_FOUND" }, isError: true })
.readRetry?.exhausted).toBe(false);
expect(section({ ...base, error: null, isError: false })
.readRetry?.exhausted).toBe(false);
});
});
@@ -1,45 +1,44 @@
import type { InvestmentResourceMeta } from "@/shared/api";
import { type InvestmentResourceMeta, readRetry } from "@/shared/api";
type SectionState = {
error: boolean;
hasData: boolean;
lastSuccessfulAt?: null | string;
loading: boolean;
partial?: boolean;
refreshing: boolean;
retry: () => void;
sourceError?: boolean;
stale?: boolean;
readRetry?: ReturnType<typeof readRetry>;
};
function section(
query: {
data: unknown;
isError: boolean;
isFetching: boolean;
meta?: InvestmentResourceMeta;
refetch: () => unknown;
},
retry?: () => unknown,
): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed";
function hasSectionData(data: unknown) {
return data !== undefined;
}
function section(query: {
data: unknown;
error?: unknown;
isError: boolean;
isFetching?: boolean;
meta?: InvestmentResourceMeta;
refetch?: () => Promise<unknown>;
}): SectionState {
const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data);
return {
error: isError,
hasData: Boolean(query.data),
lastSuccessfulAt: query.meta?.lastSuccessfulAt,
loading: !query.data && !isError,
partial: query.meta?.availability === "partial",
refreshing:
Boolean(query.data)
&& (query.isFetching
|| query.meta?.sync.state === "running"
|| query.meta?.sync.state === "queued"),
retry: () => {
void (retry ?? query.refetch)();
},
sourceError: query.meta?.sync.state === "failed",
stale: query.meta?.freshness === "stale",
hasData,
loading: !hasData && !isError,
partial: query.meta?.completeness === "incomplete",
// Each section retries only its own GET, so sections recover independently.
readRetry: query.refetch
? readRetry([
{
error: query.error,
isError: query.isError,
isFetching: query.isFetching ?? false,
refetch: query.refetch,
},
])
: undefined,
};
}
@@ -19,7 +19,6 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
const average = vm.position?.averagePurchasePrice;
const averageLine = average && props.priceUnit !== "percent"
&& average.currency === vm.candles?.currency
? Number(average.value)
: null;
@@ -47,7 +46,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) {
currency:
props.priceUnit === "percent"
? t("bond.percentOfNominal")
: (vm.candles?.currency ?? ""),
: "₽",
},
);

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