16 Commits
Author SHA1 Message Date
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
61 changed files with 1984 additions and 1880 deletions
+28 -14
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md);
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. 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 архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -57,21 +65,27 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference (`fix: … (#12)`
содержит одну связанную задачу. При связанной issue добавь её номер в конец или `chore: … (WealthLedger/main#27)`); допустим `spec:`. Каждый commit
заголовка (`fix: … #12`); без issue номер не нужен. Root другого checkout содержит одну связанную задачу. Исключения release и правила ссылки — в
не управляет этим repo. Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+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 (если есть)
+6 -2
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened]
schedule: schedule:
- cron: '17 5 * * *' - cron: '17 5 * * *'
@@ -34,7 +34,11 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: | run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict 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: security:
needs: commits needs: commits
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй 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`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+27
View File
@@ -1,5 +1,32 @@
# Changelog # Changelog
## [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) ## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/frontend/compare/v0.4.0...v1.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+6 -3
View File
@@ -203,7 +203,8 @@ Checkout frontend содержит все входы сборки и прове
локальную копию и не обращаются к исходникам или CI сервера. Доставка новой локальную копию и не обращаются к исходникам или CI сервера. Доставка новой
схемы — явное обновление snapshot с review и `npm run openapi:generate`. схемы — явное обновление snapshot с review и `npm run openapi:generate`.
Runtime URL API задаётся окружением, а не расположением другого репозитория. Runtime URL API задаётся окружением, а не расположением другого репозитория.
Разработка идёт напрямую по задаче пользователя; OpenSpec/SDD — по явному запросу. Разработка начинается с Issue; lightweight или SDD выбирается по критериям
Engineering Workflow из AGENTS. OpenSpec — после явного решения Change Owner.
Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает Knip исключает `conventional-changelog-conventionalcommits`: release-it загружает
preset по имени из `.release-it.json`. Версия preset закреплена напрямую. preset по имени из `.release-it.json`. Версия preset закреплена напрямую.
@@ -211,9 +212,11 @@ preset по имени из `.release-it.json`. Версия preset закреп
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного `develop` содержит последний релиз. Создай `release/<name>` от проверенного
+6
View File
@@ -1,6 +1,12 @@
module.exports = { module.exports = {
defaultIgnores: false,
extends: ["@commitlint/config-conventional"], 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: { rules: {
"references-empty": [2, "never"],
"type-enum": [ "type-enum": [
2, 2,
"always", "always",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"version": "1.0.0", "version": "1.1.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "wealth-ledger-frontend", "name": "wealth-ledger-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "24.21.0" "node": "24.21.0"
@@ -15,6 +15,28 @@
min-width: 0; 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 { .categories {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -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();
});
});
@@ -92,9 +92,31 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => {
</Title> </Title>
{props.data && ( {props.data && (
<Text className={classes.total}> <dl className={classes.totals}>
{formatMoney(props.data.net, { withZeroCents: true })} <div>
</Text> <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> </div>
@@ -4,17 +4,22 @@ const brokerageAccountsQueryOptions = () =>
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts"); apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey; const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
const BROKERAGE_ACCOUNTS_STALE_TIME_MS = 1_800_000;
type BrokerageAccount = NonNullable< type BrokerageAccount = NonNullable<
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"] paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"]["data"]
>[number]; >[number];
function useBrokerageAccounts() { function useBrokerageAccounts() {
return useResourceQuery({ const query = useResourceQuery({
meta: { accessGroup: "brokerage" }, meta: { accessGroup: "brokerage" },
...brokerageAccountsQueryOptions(), ...brokerageAccountsQueryOptions(),
staleTime: 60_000, staleTime: BROKERAGE_ACCOUNTS_STALE_TIME_MS,
}); });
return {
...query,
};
} }
export { export {
@@ -205,24 +205,32 @@ export const IndependentFreshness: Story
], ],
[ [
{ {
availability: "complete", availability: "available",
completeness: "complete",
dataRevision: "1", dataRevision: "1",
freshness: "stale", freshness: "stale",
lastSuccessfulAt: "2026-09-24T10:30:00Z", lastAttempt: {
resourceKey: "portfolio/storybook-account", finishedAt: "2026-09-24T10:31:00Z",
sync: { problems: [],
errorCode: "TBANK_UNAVAILABLE", startedAt: "2026-09-24T10:30:00Z",
id: "failed",
state: "failed", state: "failed",
}, },
lastSuccessfulAt: "2026-09-24T10:30:00Z",
resourceKey: "portfolio/storybook-account",
}, },
{ {
availability: "missing", availability: "initial-loading",
completeness: "incomplete",
dataRevision: "0", dataRevision: "0",
freshness: "unknown", freshness: "unknown",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-09-24T10:30:00Z",
state: "running",
},
lastSuccessfulAt: null, lastSuccessfulAt: null,
resourceKey: "portfolio/waiting", resourceKey: "portfolio/waiting",
sync: { errorCode: null, id: "waiting", state: "running" },
}, },
], ],
), ),
@@ -64,15 +64,16 @@ const BrokerageAccountSourceStatus: FC<BrokerageAccountSourceStatusProps> = (
const { source } = props; const { source } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const isSyncing = source?.sync.state === "running"; const isSyncing = source?.lastAttempt.state === "running";
const errorText const errorText
= source?.sync.state === "failed" ? t("accounts.refreshError") : null; = source?.lastAttempt.state === "failed" ? t("accounts.refreshError") : null;
return ( return (
<> <>
<SourceSyncStatus <SourceSyncStatus
errorText={errorText} errorText={errorText}
lastAttempt={source?.lastAttempt}
lastSuccessfulAt={source?.lastSuccessfulAt} lastSuccessfulAt={source?.lastSuccessfulAt}
stale={source?.freshness === "stale"} stale={source?.freshness === "stale"}
/> />
+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 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 StockDividends = NonNullable<paths["/api/v1/stocks/{uid}/dividends"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
type StockPeriod = NonNullable< type StockPeriod = NonNullable<
@@ -18,6 +14,8 @@ type StockPeriod = NonNullable<
>["period"] >["period"]
>; >;
type StockPosition = NonNullable<paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"]>;
const STOCK_STALE_TIME_MS = 30_000; const STOCK_STALE_TIME_MS = 30_000;
function useStock(identifier: string) { 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 { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -117,5 +104,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
}; };
-2
View File
@@ -1,5 +1,4 @@
export { export {
MIN_STOCK_SEARCH_LENGTH,
type StockCandles, type StockCandles,
type StockDividends, type StockDividends,
type StockPosition, type StockPosition,
@@ -9,5 +8,4 @@ export {
useStockDividends, useStockDividends,
useStockPosition, useStockPosition,
useStockQuote, useStockQuote,
useStockSearch,
} from "./api/queries"; } from "./api/queries";
@@ -5,20 +5,23 @@ import { resource } from "@/test/resource";
import { section } from "./section"; import { section } from "./section";
describe("instrument source and transport presentation", () => { 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", "keeps %s separate from successful transport",
(availability) => { (availability, completeness) => {
const data = availability === "missing" ? undefined : { rows: [] }; const data = availability === "initial-loading" ? undefined : { rows: [] };
const state = section({ const state = section({
data, data,
isError: false, isError: false,
meta: resource(data, { availability }).meta, meta: resource(data, { availability, completeness }).meta,
}); });
expect(state.loading).toBe(availability === "missing"); expect(state.loading).toBe(availability === "initial-loading");
expect(state.partial).toBe(availability === "partial"); expect(state.partial).toBe(completeness === "incomplete");
expect(state.hasData).toBe(availability !== "missing"); expect(state.hasData).toBe(availability !== "initial-loading");
}, },
); );
it("preserves empty successful data instead of treating it as missing", () => { it("preserves empty successful data instead of treating it as missing", () => {
@@ -37,7 +40,12 @@ describe("instrument source and transport presentation", () => {
it("preserves a full source when force fails", () => { it("preserves a full source when force fails", () => {
const meta = resource([], { 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; }).meta;
const state = section({ data: [], isError: false, meta }); const state = section({ data: [], isError: false, meta });
@@ -53,7 +61,12 @@ describe("instrument source and transport presentation", () => {
data: undefined, data: undefined,
isError: false, isError: false,
meta: resource(null, { 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, }).meta,
}); });
@@ -16,14 +16,14 @@ function section(query: {
isError: boolean; isError: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
}): SectionState { }): SectionState {
const isError = query.isError || query.meta?.sync.state === "failed"; const isError = query.isError || query.meta?.lastAttempt.state === "failed";
const hasData = hasSectionData(query.data); const hasData = hasSectionData(query.data);
return { return {
error: isError, error: isError,
hasData, hasData,
loading: !hasData && !isError, loading: !hasData && !isError,
partial: query.meta?.availability === "partial", partial: query.meta?.completeness === "incomplete",
}; };
} }
@@ -13,7 +13,6 @@ function isInvestmentPath(path: string) {
"/api/v1/brokerage-accounts", "/api/v1/brokerage-accounts",
"/api/v1/stocks", "/api/v1/stocks",
"/api/v1/bonds", "/api/v1/bonds",
"/api/v1/tbank-sync",
].some(root => path === root || path.startsWith(`${root}/`)); ].some(root => path === root || path.startsWith(`${root}/`));
} }
@@ -3,14 +3,12 @@ import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { useStockQuote } from "@/entities/stock"; import { configureAuthClient } from "@/shared/api";
import { configureAuthClient, invalidateResourceAccess } from "@/shared/api";
import { createQueryWrapper } from "@/test/query"; import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource"; import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { useScreenSync } from "../index"; import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
function auth() { function auth() {
configureAuthClient({ configureAuthClient({
@@ -20,364 +18,23 @@ function auth() {
}); });
} }
describe("page-owned screen synchronization", () => { describe("read-only screen synchronization", () => {
it("keeps the current scope when an older sync acceptance arrives last", async () => { it("retries the resource with GET without requesting source observation", async () => {
auth(); auth();
let gets = 0;
const { promise: blocked, resolve: release } const syncModes: (null | string)[] = [];
= Promise.withResolvers<void>();
const posted: string[] = [];
const polled: string[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
const scope = (await request.json()) as {
accountId: string;
screen: "account";
};
posted.push(scope.accountId);
if (scope.accountId === "old") await blocked;
return HttpResponse.json(
{ id: scope.accountId, now: "2026-09-25T00:00:00Z", scope },
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", ({ params }) => {
polled.push(String(params.id));
return HttpResponse.json({
id: params.id,
state: "succeeded",
});
}),
);
const { rerender, result } = renderHook(
({ accountId }) => useScreenSync({ accountId, screen: "account" }, []),
{
initialProps: { accountId: "old" },
wrapper: createQueryWrapper().wrapper,
},
);
let pending!: Promise<void>;
act(() => {
pending = result.current.refresh();
});
await waitFor(() => expect(posted).toEqual(["old"]));
rerender({ accountId: "new" });
await act(() => result.current.refresh());
await waitFor(() => expect(polled).toEqual(["new"]));
await act(async () => {
release();
await pending;
});
expect(polled).toEqual(["new"]);
expect(posted).toEqual(["old", "new"]);
});
it.each([false, true])(
"recovers a failed GET without forcing the source (cached=%s)",
async (cached) => {
auth();
const modes: (null | string)[] = [];
let isUnavailable = !cached;
let posts = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return isUnavailable
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(
resource([], { dataRevision: String(modes.length) }),
);
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
return new HttpResponse(null, { status: 500 });
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
disabled,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
if (cached) {
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(result.current.accounts.isSuccess).toBe(true),
);
isUnavailable = true;
void result.current.accounts.refetch();
}
isUnavailable = false;
await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true));
expect(posts).toBe(0);
expect(modes.every(mode => mode === null)).toBe(true);
expect(result.current.disabled.data).toBeUndefined();
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
},
);
it.each([
["logout", true],
["connection", true],
["session", false],
["connection", false],
["access", false],
] as const)(
"discards source recovery after %s changes (HTTP already pending=%s)",
async (boundary, pending) => {
auth();
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
let recoveryRequests = 0;
let isChanged = false;
try {
server.use(
http.get("*/api/v1/brokerage-accounts", async () => {
if (isChanged)
return HttpResponse.json(
resource([], { dataRevision: "current" }),
);
if (recoveryRequests > 0) {
recoveryRequests += 1;
await blocked;
return HttpResponse.json(resource([], { dataRevision: "old" }));
}
recoveryRequests += 1;
return HttpResponse.json(
resource(null, {
sync: {
errorCode: "TBANK_UNAVAILABLE",
id: "failed",
state: "failed",
},
}),
);
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const published: unknown[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (event.type === "updated" && event.action.type === "success")
published.push(event.query.state.data);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper },
);
await waitFor(() =>
expect(result.current.accounts.meta?.sync.state).toBe("failed"),
);
vi.useFakeTimers();
if (pending)
await act(() => vi.advanceTimersByTimeAsync(5000));
expect(recoveryRequests).toBeGreaterThanOrEqual(1);
await act(async () => {
isChanged = true;
switch (boundary) {
case "access": {
invalidateResourceAccess(queryClient, "brokerage", {
code: "FORBIDDEN",
});
break;
}
case "logout": {
configureAuthClient();
queryClient.clear();
break;
}
case "session": {
auth();
queryClient.clear();
break;
}
case "connection": {
commitTbankConnectionBoundary(queryClient, {
connected: true,
connectedAt: "2026-09-27T00:00:00Z",
});
}
}
release();
if (!pending) await vi.advanceTimersByTimeAsync(5000);
});
vi.useRealTimers();
if (boundary === "connection" && !pending)
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(result.current.accounts.meta?.dataRevision).toBe("current"),
);
expect(published).not.toContainEqual(
expect.objectContaining({
meta: expect.objectContaining({ dataRevision: "old" }) as unknown,
}),
);
unsubscribe();
}
finally {
vi.useRealTimers();
}
},
);
it.each([false, true])(
"recovers an initial GET failure without forced sync (status retry=%s)",
async (retryStatus) => {
auth();
const modes: (null | string)[] = [];
let posts = 0;
let polls = 0;
try {
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return modes.length === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json(resource([]));
}),
http.post("*/api/v1/tbank-sync", () => {
posts += 1;
return HttpResponse.json({ id: "recover" }, { status: 202 });
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls += 1;
return retryStatus && polls === 1
? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 })
: HttpResponse.json({
id: "recover",
state: "succeeded",
});
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
const disabled = useStockQuote("");
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts, disabled]),
};
},
{ wrapper },
);
await waitFor(
() => expect(result.current.accounts.isSuccess).toBe(true),
{ timeout: 3000 },
);
expect(result.current.accounts.data).toEqual([]);
expect(modes).toEqual([null, null]);
expect(posts).toBe(0);
expect(polls).toBe(0);
expect(
queryClient
.getQueryCache()
.findAll({ queryKey: ["get", "/api/v1/brokerage-accounts"] }),
).toHaveLength(1);
}
finally {
vi.useRealTimers();
}
},
);
it("posts only its scope once and observes completion without importing again", async () => {
auth();
const modes: (null | string)[] = [];
const bodies: unknown[] = [];
let isCompleted = false;
let polls = 0;
const meta = () =>
resource([], { dataRevision: isCompleted ? "2" : "1" }).meta;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => { http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync")); gets += 1;
syncModes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(resource([], meta())); return gets === 1
}), ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
http.post("*/api/v1/tbank-sync", async ({ request }) => { : HttpResponse.json(resource([]));
bodies.push(await request.json());
isCompleted = true;
return HttpResponse.json(
{
id: "screen",
now: "2026-09-25T00:00:00Z",
scope: { screen: "accounts" },
},
{ status: 202 },
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json(
{
errorCode: null,
id: "screen",
now: "2026-09-25T00:00:00Z",
scope: { screen: "accounts" },
state: "succeeded",
},
);
}), }),
); );
const { result, unmount } = renderHook( const { result } = renderHook(
() => { () => {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
@@ -389,382 +46,36 @@ describe("page-owned screen synchronization", () => {
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(gets).toBe(1));
await act(async () => result.current.sync.retry());
await waitFor(() => expect(result.current.accounts.data).toEqual([])); await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(async () => {
await Promise.all([
result.current.sync.refresh(),
result.current.sync.refresh(),
]);
});
await waitFor(() =>
expect(result.current.accounts.meta?.dataRevision).toBe("2"),
);
expect(bodies).toEqual([{ screen: "accounts" }]);
expect(modes).toEqual([null, "observe"]); expect(gets).toBe(2);
vi.useFakeTimers(); expect(syncModes).toEqual([null, null]);
try {
await act(() => vi.advanceTimersByTimeAsync(120_000));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
unmount();
await vi.advanceTimersByTimeAsync(120_000);
expect(polls).toBe(1);
}
finally {
vi.useRealTimers();
}
});
it.each(["leave", "logout"])(
"publishes completed empty coverage while history runs, then stops on %s",
async (boundary) => {
vi.useFakeTimers();
try {
auth();
let isCompleted = false;
let polls = 0;
const modes: (null | string)[] = [];
const snapshot = () =>
resource(isCompleted ? [] : null, {
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
unavailableReason: isCompleted
? undefined
: "CALENDAR_OR_ACCOUNT_HISTORY",
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(snapshot());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json(
{
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "running",
},
);
}),
);
const { result, unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
expect(result.current.sync.refreshing).toBe(true);
expect(result.current.accounts.data).toBeUndefined();
isCompleted = true;
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() =>
expect(result.current.accounts.data).toEqual([]),
);
expect(result.current.sync.refreshing).toBe(true);
expect(modes).toEqual([null, "observe", "observe"]);
if (boundary === "leave") unmount();
else act(() => configureAuthClient());
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
},
);
it("runs the next status poll and observe read only after five seconds", async () => {
auth();
const modes: (null | string)[] = [];
const pollGaps: number[] = [];
let polls = 0;
let previousPollAt = 0;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(
resource([], {
sync: { errorCode: null, id: "history", state: "running" },
}),
);
}),
http.get("*/api/v1/tbank-sync/:id", () => {
const now = Date.now();
if (previousPollAt) pollGaps.push(now - previousPollAt);
previousPollAt = now;
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "running",
});
}),
);
vi.useFakeTimers();
try {
const { unmount } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
await act(() => vi.advanceTimersByTimeAsync(2500));
expect(polls).toBe(1);
expect(modes).toEqual([null, "observe"]);
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(polls).toBe(2));
expect(modes).toEqual([null, "observe", "observe"]);
expect(pollGaps).toHaveLength(1);
expect(pollGaps[0]).toBeGreaterThanOrEqual(5000);
unmount();
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
});
it("observes resources immediately when a status response turns terminal", async () => {
auth();
const modes: (null | string)[] = [];
let isCompleted = false;
let polls = 0;
const meta = () =>
resource([], {
dataRevision: isCompleted ? "covered" : "initial",
sync: { errorCode: null, id: "history", state: "running" },
});
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
modes.push(new URL(request.url).searchParams.get("sync"));
return HttpResponse.json(meta());
}),
http.get("*/api/v1/tbank-sync/:id", () => {
polls++;
return HttpResponse.json({
errorCode: null,
id: "history",
now: "2026-09-25T00:00:00Z",
scope: null,
state: isCompleted ? "succeeded" : "running",
});
}),
);
vi.useFakeTimers();
try {
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await vi.waitFor(() => expect(modes).toEqual([null, "observe"]));
expect(polls).toBe(1);
expect(result.current.sync.refreshing).toBe(true);
isCompleted = true;
await act(() => vi.advanceTimersByTimeAsync(5000));
await vi.waitFor(() => expect(modes).toEqual([null, "observe", "observe"]));
expect(polls).toBe(2);
expect(result.current.accounts.meta?.dataRevision).toBe("covered");
expect(result.current.sync.refreshing).toBe(false);
await act(() => vi.advanceTimersByTimeAsync(60_000));
expect(polls).toBe(2);
expect(modes).toHaveLength(3);
}
finally {
vi.useRealTimers();
}
}); });
it("clears private snapshots when status confirms access loss", async () => { it("refreshes the currently open resources together", async () => {
auth(); auth();
let gets = 0;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () => {
HttpResponse.json( gets += 1;
resource(
[], return HttpResponse.json(resource([]));
{ }),
sync: { errorCode: null, id: "revoked", state: "running" },
},
),
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json({ code: "INVALID_TBANK_TOKEN" }, { status: 400 }),
),
); );
const { result } = renderHook( const { result } = renderHook(
() => { () => {
const accounts = useBrokerageAccounts(); const accounts = useBrokerageAccounts();
return { return useScreenSync({ screen: "accounts" }, [accounts]);
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
}, },
{ wrapper: createQueryWrapper().wrapper }, { wrapper: createQueryWrapper().wrapper },
); );
await waitFor(() => expect(result.current.accounts.isError).toBe(true)); await waitFor(() => expect(gets).toBe(1));
expect(result.current.accounts.data).toBeUndefined(); await act(async () => result.current.refresh());
}); await waitFor(() => expect(gets).toBe(2));
it("observes a projection when only its dependency reports new coverage", async () => {
auth();
let isObserved = false;
const modes: (null | string)[] = [];
const dependency = resource(
[],
{
dataRevision: isObserved ? "covered" : "initial",
resourceKey: "operations",
sync: {
errorCode: null,
id: "dependency",
state: isObserved ? "idle" : "running",
},
},
).meta;
server.use(
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
const mode = new URL(request.url).searchParams.get("sync");
modes.push(mode);
if (mode) isObserved = true;
return HttpResponse.json(
resource(
isObserved
? []
: null,
{
dependencies: [
{
...dependency,
dataRevision: isObserved ? "covered" : "initial",
},
],
resourceKey: "projection",
},
),
);
}),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "dependency",
now: "2026-09-25T00:00:00Z",
scope: null,
state: "succeeded",
},
),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return accounts;
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.data).toEqual([]));
expect(modes).toEqual([null, "observe"]);
});
it("preserves a fresh snapshot and timestamp after a failed force", async () => {
auth();
const saved = resource([], { lastSuccessfulAt: "2026-09-25T01:00:00Z" });
server.use(
http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json(saved)),
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }),
),
);
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
return {
accounts,
sync: useScreenSync({ screen: "accounts" }, [accounts]),
};
},
{ wrapper: createQueryWrapper().wrapper },
);
await waitFor(() => expect(result.current.accounts.data).toEqual([]));
await act(() => result.current.sync.refresh());
await waitFor(() => expect(result.current.sync.transportError).toBe(true));
expect(result.current.accounts.meta).toEqual(saved.meta);
}); });
}); });
@@ -1,269 +1,52 @@
import { import type { QueryKey } from "@tanstack/react-query";
focusManager,
onlineManager,
type QueryKey,
useQueryClient,
} from "@tanstack/react-query";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import type { InvestmentResourceMeta } from "@/shared/api";
ApiErrorCode,
apiErrorCode,
apiQueryClient,
invalidateResourceAccess,
type InvestmentResourceMeta,
isApiErrorCode,
type paths,
useAuthenticatedQueries,
} from "@/shared/api";
type InvestmentScreen
= paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"];
type ScreenResource = { type ScreenResource = {
isError: boolean; isError: boolean;
isFetching: boolean; isFetching: boolean;
meta?: InvestmentResourceMeta; meta?: InvestmentResourceMeta;
observe: () => Promise<unknown>;
queryKey: QueryKey; queryKey: QueryKey;
refetch: () => Promise<unknown>; refetch: () => Promise<unknown>;
}; };
const STATUS_INTERVAL_MS = 5000; type ScreenScope = {
const FIRST_SOURCE_RECOVERY_DELAY_MS = 5000; [key: string]: unknown;
const SECOND_SOURCE_RECOVERY_DELAY_MS = 15_000; screen: string;
};
function requiresSourceRecovery(meta?: InvestmentResourceMeta): boolean { const READ_INTERVAL_MS = 1_800_000;
if (!meta || meta.unavailableReason) return false;
return ["cancelled", "failed"].includes(meta.sync.state) function useScreenSync(_scope: ScreenScope, resources: ScreenResource[]) {
|| meta.availability === "missing" const resourcesRef = useRef(resources);
|| meta.freshness === "stale" useEffect(() => {
|| (meta.dependencies ?? []).some(dependency => resourcesRef.current = resources;
requiresSourceRecovery(dependency), }, [resources]);
);
}
function sourceRecoveryDelay(attempt: number): number | undefined {
switch (attempt) {
case 0: {
return FIRST_SOURCE_RECOVERY_DELAY_MS;
}
case 1: {
return SECOND_SOURCE_RECOVERY_DELAY_MS;
}
default: {
return undefined;
}
}
}
function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
const mutation = apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
});
const client = useQueryClient();
const deniedRef = useRef(new Set<unknown>());
const scopeKey = JSON.stringify(scope);
const requestInFlightRef = useRef<null | string>(null);
const lifecycleRecoveryRef = useRef(new Set<string>());
const sourceRecoveryRef = useRef(new Map<string, number>());
const sourceTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
const isCurrentMutation
= JSON.stringify(mutation.variables?.body) === scopeKey;
const observedRef = useRef("");
const ids = [
...new Set([
...(isCurrentMutation && mutation.data ? [mutation.data.id] : []),
...resources
.flatMap(({ meta }) =>
meta ? [meta, ...(meta.dependencies ?? [])] : [],
)
.flatMap(meta =>
meta.sync.id
&& meta.sync.state === "running"
? [meta.sync.id]
: [],
),
]),
];
const statuses = useAuthenticatedQueries(
ids.map(id => ({
...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", {
params: { path: { id } },
}),
refetchInterval: (query: {
state: { data?: { state: string }; error: unknown };
}) =>
!(
query.state.error
|| (query.state.data && query.state.data.state !== "running")
) && STATUS_INTERVAL_MS,
refetchOnMount: false,
refetchOnWindowFocus: false,
retry: false,
})),
);
useEffect(() => { useEffect(() => {
for (const status of statuses) { const timer = setInterval(() => {
if (!status.error || deniedRef.current.has(status.error)) { for (const resource of resourcesRef.current)
continue;
}
deniedRef.current.add(status.error);
if (!isApiErrorCode(apiErrorCode(status.error), ApiErrorCode.NotFound))
invalidateResourceAccess(client, "brokerage", status.error);
}
if (statuses.every(status => !status.data)) return;
const observation = JSON.stringify([
scopeKey,
statuses.map(status => [
status.data?.id,
status.data?.state,
status.dataUpdatedAt,
]),
resources.map(resource => resource.queryKey),
]);
if (observedRef.current === observation) return;
observedRef.current = observation;
const isCompleted = statuses.every(
status => status.data && status.data.state !== "running",
);
for (const resource of resources)
if (isCompleted || !resource.isFetching)
void resource.observe().catch(() => {});
}, [client, statuses, resources, scopeKey]);
useEffect(() => {
const timers = sourceTimersRef.current;
const attempts = sourceRecoveryRef.current;
return () => {
for (const timer of timers) clearTimeout(timer);
timers.clear();
attempts.clear();
};
}, [scopeKey]);
useEffect(() => {
for (const resource of resources) {
if (
!requiresSourceRecovery(resource.meta)
|| resource.isFetching
) continue;
const key = JSON.stringify([scopeKey, resource.queryKey]);
const attempt = sourceRecoveryRef.current.get(key) ?? 0;
const delay = sourceRecoveryDelay(attempt);
if (delay === undefined) continue;
sourceRecoveryRef.current.set(key, attempt + 1);
const timer = setTimeout(() => {
sourceTimersRef.current.delete(timer);
void resource.refetch().catch(() => {}); void resource.refetch().catch(() => {});
}, delay); }, READ_INTERVAL_MS);
sourceTimersRef.current.add(timer); return () => clearInterval(timer);
} }, []);
}, [resources, scopeKey]);
useEffect(() => { const isRefreshing = resources.some(resource => resource.isFetching);
const recover = () => {
for (const resource of resources) {
if (
!requiresSourceRecovery(resource.meta)
|| resource.isFetching
) continue;
const key = JSON.stringify([scopeKey, resource.queryKey]);
if (lifecycleRecoveryRef.current.has(key)) continue;
sourceRecoveryRef.current.delete(key);
lifecycleRecoveryRef.current.add(key);
void resource.refetch()
.catch(() => {})
.finally(() => lifecycleRecoveryRef.current.delete(key));
}
};
const unsubscribeFocus = focusManager.subscribe((focused) => {
if (focused) recover();
});
const unsubscribeOnline = onlineManager.subscribe((online) => {
if (online) recover();
});
return () => {
unsubscribeFocus();
unsubscribeOnline();
};
}, [resources, scopeKey]);
const isRefreshing
= (isCurrentMutation && mutation.isPending)
|| statuses.some(
status => !status.isError && status.data?.state === "running",
)
|| resources.some(({ meta }) => {
const status = statuses[ids.indexOf(meta?.sync.id ?? "")];
return (
!status?.isError
&& (!status?.data || status.data.state === "running")
&& meta?.sync.state === "running"
);
});
const refresh = async () => { const refresh = async () => {
if (isRefreshing || requestInFlightRef.current === scopeKey) return; if (isRefreshing) return;
requestInFlightRef.current = scopeKey; await Promise.allSettled(resources.map(resource => resource.refetch()));
try {
await mutation.mutateAsync({ body: scope });
}
catch {
/*
The mutation retains the transport error for presentation.
*/
}
finally {
if (requestInFlightRef.current === scopeKey)
requestInFlightRef.current
= null;
}
}; };
// Retry failed HTTP reads without asking the source to import again. // Retry only the HTTP read; source attempts are reported in resource metadata.
// A source failure (or explicit refresh with no read error) still needs a sync.
const retry = async () => { const retry = async () => {
const failed = resources.filter( const failed = resources.filter(resource => resource.isError);
resource => resource.isError,
);
const failedStatuses = statuses.filter(status => status.isError); if (failed.length > 0)
if (failed.length > 0 || failedStatuses.length > 0) { await Promise.allSettled(failed.map(resource => resource.refetch()));
await Promise.allSettled([
...failed.map(resource => resource.observe()),
...failedStatuses.map(status => status.refetch()),
]);
}
else await refresh(); else await refresh();
}; };
@@ -271,13 +54,11 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) {
refresh, refresh,
refreshing: isRefreshing, refreshing: isRefreshing,
retry, retry,
sourceError: sourceError: resources.some(
statuses.some(status => status.data?.state === "failed") ({ meta }) => meta?.lastAttempt.state === "failed",
|| resources.some(({ meta }) => meta?.sync.state === "failed"), ),
transportError: transportError: resources.some(resource => resource.isError),
(isCurrentMutation && mutation.isError)
|| statuses.some(status => status.isError),
}; };
} }
export { type InvestmentScreen, useScreenSync }; export { useScreenSync };
@@ -1,148 +0,0 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { describe, expect, it, vi } from "vitest";
import { useBrokerageAccounts } from "@/entities/brokerage-account";
import { apiQueryClient, configureAuthClient } from "@/shared/api";
import { createQueryWrapper } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import type { InvestmentScreen } from "./screen-sync";
import { useScreenSync } from "../index";
import { commitTbankConnectionBoundary } from "./connection-boundary";
describe("sync connection and session boundaries", () => {
it.each(["connection", "session"])(
"keeps old status out of the cache after %s changes",
async (boundary) => {
let generation = 0;
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
generation: () => generation,
refresh: () => Promise.resolve(true),
});
const { promise: blocked, resolve: release }
= Promise.withResolvers<void>();
const accepted = {
id: "work",
now: "2026-09-25T12:00:00Z",
scope: { screen: "accounts" },
};
let started = 0;
let isChanged = false;
server.use(
http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], {
sync: { errorCode: null, id: "work", state: "running" },
})),
),
http.post("*/api/v1/tbank-sync", async () => {
started += 1;
await blocked;
return HttpResponse.json(accepted, { status: 202 });
}),
http.get("*/api/v1/tbank-sync/:id", async () => {
const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now;
started += 1;
await blocked;
return HttpResponse.json(
{
...accepted,
errorCode: null,
now,
state: "succeeded",
},
);
}),
);
const { queryClient, wrapper } = createQueryWrapper();
const published: string[] = [];
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (
(event.query.queryKey as readonly unknown[])[1]
!== "/api/v1/tbank-sync/{id}"
) {
return;
}
const data = event.query.state.data as undefined | { now?: string };
if (data?.now) published.push(data.now);
});
const { result } = renderHook(
() => {
const accounts = useBrokerageAccounts();
useScreenSync({ screen: "accounts" }, [accounts]);
return {
mutation: apiQueryClient.useMutation("post", "/api/v1/tbank-sync", {
retry: false,
}),
};
},
{ wrapper },
);
const screen: InvestmentScreen = { screen: "accounts" };
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutation.mutateAsync({ body: screen });
});
// eslint-disable-next-line unicorn/prefer-await -- Handle rejection before changing the boundary and releasing the pending response.
const settled = mutation.catch(() => {});
await waitFor(() => expect(started).toBe(2));
await act(async () => {
isChanged = true;
if (boundary === "connection")
commitTbankConnectionBoundary(
queryClient,
{
connected: false,
connectedAt: null,
},
);
else {
generation += 1;
queryClient.clear();
}
release();
await blocked;
});
await settled;
const key = apiQueryClient.queryOptions(
"get",
"/api/v1/tbank-sync/{id}",
{ params: { path: { id: "work" } } },
).queryKey;
// A new request may start after a successful connection boundary; an old result cannot win it.
if (boundary === "session")
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(queryClient.getQueryData(key)).toBeUndefined();
else
await waitFor(() =>
// eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch.
expect(queryClient.getQueryData(key)).toMatchObject({
now: "2026-09-25T12:01:00Z",
}),
);
expect(published).not.toContain(accepted.now);
unsubscribe();
},
);
});
@@ -123,9 +123,10 @@ describe("Upcoming event date filters", () => {
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -309,7 +310,14 @@ it("retains the previous complete forecast when its source refresh fails", async
const snapshot = resource( const snapshot = resource(
{ events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] },
{ sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } }, {
lastAttempt: {
finishedAt: "2026-08-14T00:01:00Z",
problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed",
},
},
); );
server.use( server.use(
@@ -51,8 +51,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -85,8 +86,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -138,8 +140,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: false, isLoading: false,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -194,8 +197,9 @@ describe("AccountEventsPage", () => {
isError: false, isError: false,
isLoading: true, isLoading: true,
meta: { meta: {
availability: "complete", availability: "available",
sync: { errorCode: null, id: null, state: "idle" }, completeness: "complete",
lastAttempt: { finishedAt: null, problems: [], startedAt: null, state: "none" },
}, },
refetch: vi.fn(), refetch: vi.fn(),
} as never, } as never,
@@ -1,6 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageEvent, type BrokerageEvent,
useBrokerageEvents, useBrokerageEvents,
@@ -25,6 +27,7 @@ type AccountEventsPageViewModel
onPeriodPopoverOpen: () => void; onPeriodPopoverOpen: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
@@ -74,7 +77,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!events.data, hasContent: !!events.data,
identity: scope, identity: scope,
isError: events.isError || events.meta?.sync.state === "failed", isError: events.isError || events.meta?.lastAttempt.state === "failed",
isFetching: events.isFetching, isFetching: events.isFetching,
}); });
@@ -86,7 +89,7 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
setSearchParameters({}); setSearchParameters({});
}; };
if ((events.isError || events.meta?.sync.state === "failed") && !events.data) if ((events.isError || events.meta?.lastAttempt.state === "failed") && !events.data)
return { return {
onReset, onReset,
state: "error", state: "error",
@@ -115,7 +118,8 @@ function useAccountEventsPageViewModel(): AccountEventsPageViewModel {
), ),
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onReset, onReset,
partial: events.meta?.availability !== "complete", partial: events.meta?.completeness === "incomplete",
source: events.meta,
state: "loaded", state: "loaded",
}; };
} }
@@ -15,6 +15,7 @@ import {
BrokerageEventCard, BrokerageEventCard,
brokerageEventKey, brokerageEventKey,
} from "@/entities/brokerage-event"; } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm";
@@ -55,6 +56,12 @@ const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
<Title order={1}>{t("events.title")}</Title> <Title order={1}>{t("events.title")}</Title>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
<Text c="dimmed">{t("events.hint")}</Text> <Text c="dimmed">{t("events.hint")}</Text>
@@ -136,13 +136,14 @@ it("maps comparison notices by role regardless of dependency order or resource k
const previous const previous
= { = {
...resource(null).meta, ...resource(null).meta,
resourceKey: "opaque/year-looking-key", lastAttempt: {
role: "previous" as const, finishedAt: "2026-08-14T00:01:00Z",
sync: { problems: [],
errorCode: "TBANK_UNAVAILABLE", startedAt: "2026-08-14T00:00:00Z",
id: null,
state: "failed" as const, state: "failed" as const,
}, },
resourceKey: "opaque/year-looking-key",
role: "previous" as const,
}; };
const year = { const year = {
+1 -1
View File
@@ -66,7 +66,7 @@ function comparisonNotice(
): ComparisonNotice { ): ComparisonNotice {
if (!dependency || dependency.unavailableReason) return unavailable; if (!dependency || dependency.unavailableReason) return unavailable;
return dependency.sync.state === "failed" return dependency.lastAttempt.state === "failed"
? "comparisonFailed" ? "comparisonFailed"
: "comparisonPending"; : "comparisonPending";
} }
@@ -205,9 +205,10 @@ describe("AccountIncomePage", () => {
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -832,12 +833,13 @@ it("keeps complete previous comparison while year source failed without inventin
}, },
{ {
...resource(null).meta, ...resource(null).meta,
role: "year" as const, lastAttempt: {
sync: { finishedAt: "2026-08-14T00:01:00Z",
errorCode: "TBANK_UNAVAILABLE", problems: [],
id: null, startedAt: "2026-08-14T00:00:00Z",
state: "failed" as const, state: "failed" as const,
}, },
role: "year" as const,
}, },
{ {
...resource(analytics).meta, ...resource(analytics).meta,
@@ -79,7 +79,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!analytics.data, hasContent: !!analytics.data,
identity: `${accountId}:${period.from}:${period.to}`, identity: `${accountId}:${period.from}:${period.to}`,
isError: analytics.isError || analytics.meta?.sync.state === "failed", isError: analytics.isError || analytics.meta?.lastAttempt.state === "failed",
isFetching: analytics.isFetching, isFetching: analytics.isFetching,
}); });
@@ -114,7 +114,7 @@ const useAccountIncomePageViewModel = (): AccountIncomePageViewModel => {
formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`, formRevision: `${accountId}:${period.from}:${period.to}:${resetRevision}`,
onReset, onReset,
onSubmit, onSubmit,
partial: analytics.meta?.availability !== "complete", partial: analytics.meta?.completeness === "incomplete",
state: "loaded", state: "loaded",
}; };
}; };
@@ -497,7 +497,6 @@ describe("AccountOperationsPage", () => {
it("keeps UTC operation boundaries in GET and source sync requests", async () => { it("keeps UTC operation boundaries in GET and source sync requests", async () => {
configureTestAuth(); configureTestAuth();
const requested: Record<string, string>[] = []; const requested: Record<string, string>[] = [];
const syncRequests: unknown[] = [];
server.use( server.use(
http.get( http.get(
"*/api/v1/brokerage-accounts/:accountId/operations", "*/api/v1/brokerage-accounts/:accountId/operations",
@@ -508,9 +507,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
resource( resource(
null, null,
{ {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: null, problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}, },
@@ -518,11 +518,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
); );
}, },
), ),
http.post("*/api/v1/tbank-sync", async ({ request }) => {
syncRequests.push(await request.json());
return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
}),
); );
renderPage("?from=2024-02-29&to=2024-03-01&categories=trade"); renderPage("?from=2024-02-29&to=2024-03-01&categories=trade");
await screen.findByRole("button", { name: "Сбросить" }); await screen.findByRole("button", { name: "Сбросить" });
@@ -534,7 +529,6 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () =>
to: "2024-03-01T23:59:59.999Z", to: "2024-03-01T23:59:59.999Z",
}, },
]); ]);
expect(syncRequests).toEqual([]);
}); });
it("does not label incomplete empty operations as a confirmed empty period", async () => { it("does not label incomplete empty operations as a confirmed empty period", async () => {
@@ -550,7 +544,7 @@ it("does not label incomplete empty operations as a confirmed empty period", asy
items: [], items: [],
nextCursor: null, nextCursor: null,
}, },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageOperation, type BrokerageOperation,
brokerageOperationsQueryOptions, brokerageOperationsQueryOptions,
@@ -39,6 +41,7 @@ type AccountOperationsPageViewModel
onPrevious: () => void; onPrevious: () => void;
onReset: () => void; onReset: () => void;
partial?: boolean; partial?: boolean;
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
} }
| { onReset: () => void; state: "error" } | { onReset: () => void; state: "error" }
@@ -98,7 +101,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!operations.data, hasContent: !!operations.data,
identity: scope, identity: scope,
isError: operations.isError || operations.meta?.sync.state === "failed", isError: operations.isError || operations.meta?.lastAttempt.state === "failed",
isFetching: operations.isFetching, isFetching: operations.isFetching,
}); });
@@ -127,7 +130,7 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
if ( if (
(isInvalidCursor || !operations.data) (isInvalidCursor || !operations.data)
&& (operations.isError || operations.meta?.sync.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { onReset, state: "error" }; return { onReset, state: "error" };
@@ -177,7 +180,8 @@ const useAccountOperationsPageVm = (): AccountOperationsPageViewModel => {
onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())), onPeriodPopoverOpen: () => setMaxDate(moscowDate(new Date())),
onPrevious: history.onPrevious, onPrevious: history.onPrevious,
onReset, onReset,
partial: operations.meta?.availability !== "complete", partial: operations.meta?.completeness === "incomplete",
source: operations.meta,
state: "loaded", state: "loaded",
}; };
}; };
@@ -13,6 +13,7 @@ import { StateView } from "@wealthledger/ui";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation"; import { LedgerEntryRow, operationCategories } from "@/entities/broker-operation";
import { SourceSyncStatus } from "@/shared/ui";
import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm";
@@ -62,6 +63,12 @@ const AccountOperationsPageLoaded: FC<AccountOperationsPageLoadedProps> = (
<Stack gap="lg"> <Stack gap="lg">
<Title order={1}>{t("ledger.title")}</Title> <Title order={1}>{t("ledger.title")}</Title>
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>} {!!props.partial && <Alert role="status">{t("investment.partial")}</Alert>}
{!!props.historyLoading && <Alert>{t("investment.history")}</Alert>} {!!props.historyLoading && <Alert>{t("investment.history")}</Alert>}
+12 -11
View File
@@ -46,9 +46,10 @@ type PreviewResource<T> = {
isError: boolean; isError: boolean;
meta?: Pick<InvestmentResourceMeta, meta?: Pick<InvestmentResourceMeta,
"availability" "availability"
| "completeness"
| "freshness" | "freshness"
| "lastAttempt"
| "lastSuccessfulAt" | "lastSuccessfulAt"
| "sync"
>; >;
}; };
@@ -57,10 +58,10 @@ function toIncomeSummary(
income: PreviewResource<IncomeSummary>, income: PreviewResource<IncomeSummary>,
): AccountPageIncomeSummary { ): AccountPageIncomeSummary {
const to = `/accounts/${accountId}/income`; const to = `/accounts/${accountId}/income`;
if (!income.data && !income.isError && income.meta?.sync.state !== "failed") if (!income.data && !income.isError && income.meta?.lastAttempt.state !== "failed")
return { state: "loading", to }; return { state: "loading", to };
if (income.meta?.availability === "partial") return { state: "partial", to }; if (income.meta?.completeness === "incomplete") return { state: "partial", to };
if (!income.data && (income.isError || income.meta?.sync.state === "failed")) if (!income.data && (income.isError || income.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; if (!income.data?.totalsByCurrency.length) return { state: "empty", to };
@@ -76,15 +77,15 @@ function toRecentEvents(
events: PreviewResource<{ events: BrokerageEvent[] }>, events: PreviewResource<{ events: BrokerageEvent[] }>,
): AccountPageRecentEvents { ): AccountPageRecentEvents {
const to = `/accounts/${accountId}/events`; const to = `/accounts/${accountId}/events`;
if (!events.data && !events.isError && events.meta?.sync.state !== "failed") if (!events.data && !events.isError && events.meta?.lastAttempt.state !== "failed")
return { state: "loading", to }; return { state: "loading", to };
if (events.meta?.availability === "partial") if (events.meta?.completeness === "incomplete")
return { return {
items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [],
state: "partial", state: "partial",
to, to,
}; };
if (!events.data && (events.isError || events.meta?.sync.state === "failed")) if (!events.data && (events.isError || events.meta?.lastAttempt.state === "failed"))
return { state: "error", to }; return { state: "error", to };
const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [];
if (items.length === 0) return { state: "empty", to }; if (items.length === 0) return { state: "empty", to };
@@ -104,14 +105,14 @@ function toRecentOperations(
if ( if (
!operations.data !operations.data
&& !operations.isError && !operations.isError
&& operations.meta?.sync.state !== "failed" && operations.meta?.lastAttempt.state !== "failed"
) )
return { state: "loading", to }; return { state: "loading", to };
if (operations.meta?.availability === "partial") if (operations.meta?.completeness === "incomplete")
return { items: operations.data?.items ?? [], state: "partial", to }; return { items: operations.data?.items ?? [], state: "partial", to };
if ( if (
!operations.data !operations.data
&& (operations.isError || operations.meta?.sync.state === "failed") && (operations.isError || operations.meta?.lastAttempt.state === "failed")
) )
return { state: "error", to }; return { state: "error", to };
if (!operations.data?.items.length) return { state: "empty", to }; if (!operations.data?.items.length) return { state: "empty", to };
@@ -120,7 +121,7 @@ function toRecentOperations(
items: operations.data.items, items: operations.data.items,
lastSuccessfulAt: operations.meta?.lastSuccessfulAt, lastSuccessfulAt: operations.meta?.lastSuccessfulAt,
refreshError: refreshError:
operations.isError || operations.meta?.sync.state === "failed", operations.isError || operations.meta?.lastAttempt.state === "failed",
stale: operations.meta?.freshness === "stale", stale: operations.meta?.freshness === "stale",
state: "loaded", state: "loaded",
to, to,
@@ -770,28 +770,6 @@ describe("AccountPage states", () => {
let requestCount = 0; let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined; let resolveRetry: ((response: Response) => void) | undefined;
server.use( server.use(
http.post("*/api/v1/tbank-sync", () =>
HttpResponse.json(
{
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
},
{ status: 202 },
),
),
http.get("*/api/v1/tbank-sync/:id", () =>
HttpResponse.json(
{
errorCode: null,
id: "overview",
now: "2026-09-25T00:00:00Z",
scope: { accountId: "account-1", screen: "account" },
state: "succeeded",
},
),
),
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1; requestCount += 1;
if (requestCount === 1) if (requestCount === 1)
@@ -173,14 +173,14 @@ it("keeps overview sections independent when their source data is incomplete", a
mockLoadedAccountPage(); mockLoadedAccountPage();
server.use( server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json(resource(portfolio, { availability: "partial" })), HttpResponse.json(resource(portfolio, { completeness: "incomplete" })),
), ),
http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => http.get("*/api/v1/brokerage-accounts/:accountId/operations", () =>
HttpResponse.json( HttpResponse.json(
resource( resource(
{ hasNext: false, items: [], nextCursor: null }, { hasNext: false, items: [], nextCursor: null },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -188,7 +188,7 @@ it("keeps overview sections independent when their source data is incomplete", a
HttpResponse.json( HttpResponse.json(
resource( resource(
{ events: [], incomeTotalsByCurrency: [] }, { events: [], incomeTotalsByCurrency: [] },
{ availability: "partial" }, { completeness: "incomplete" },
), ),
), ),
), ),
@@ -1,6 +1,7 @@
import { useParams } from "react-router"; import { useParams } from "react-router";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { useBrokerageOperations } from "@/entities/broker-operation"; import { useBrokerageOperations } from "@/entities/broker-operation";
import { import {
@@ -45,6 +46,7 @@ type AccountPageEmptyViewModel = AccountPageContent & {
type AccountPageLoadedViewModel = AccountPageContent & { type AccountPageLoadedViewModel = AccountPageContent & {
portfolio: Portfolio; portfolio: Portfolio;
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
source?: InvestmentResourceMeta;
state: "loaded"; state: "loaded";
}; };
@@ -87,7 +89,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: !!portfolio.data, hasContent: !!portfolio.data,
identity: accountId ?? "account", identity: accountId ?? "account",
isError: portfolio.isError || portfolio.meta?.sync.state === "failed", isError: portfolio.isError || portfolio.meta?.lastAttempt.state === "failed",
isFetching: portfolio.isFetching, isFetching: portfolio.isFetching,
}); });
@@ -105,7 +107,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
const incomeSummary = toIncomeSummary(accountId, income); const incomeSummary = toIncomeSummary(accountId, income);
if ( if (
portfolio.meta?.availability === "complete" portfolio.meta?.availability === "available"
&& portfolio.data.positions.length === 0 && portfolio.data.positions.length === 0
&& portfolio.data.cashBalances.length === 0 && portfolio.data.cashBalances.length === 0
) )
@@ -118,7 +120,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
state: "empty" as const, state: "empty" as const,
}; };
const isPartial = portfolio.meta?.availability === "partial"; const isPartial = portfolio.meta?.completeness === "incomplete";
return { return {
account, account,
@@ -129,6 +131,7 @@ function useAccountPageViewModel(): AccountPageViewModel {
positionSections: toAccountPagePositionSections(portfolio.data), positionSections: toAccountPagePositionSections(portfolio.data),
recentEvents, recentEvents,
recentOperations, recentOperations,
source: portfolio.meta,
state: "loaded" as const, state: "loaded" as const,
}; };
} }
@@ -5,8 +5,10 @@ import { useTranslation } from "react-i18next";
import type { BrokerageAccount } from "@/entities/brokerage-account"; import type { BrokerageAccount } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio"; import type { Portfolio } from "@/entities/portfolio";
import type { InvestmentResourceMeta } from "@/shared/api";
import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { BrokerageEventsPreview } from "@/entities/brokerage-event";
import { SourceSyncStatus } from "@/shared/ui";
import { PortfolioStructure } from "@/widgets/portfolio-structure"; import { PortfolioStructure } from "@/widgets/portfolio-structure";
import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPagePositionSection } from "../../../model/positions";
@@ -32,6 +34,7 @@ type AccountPageLoadedProps = {
positionSections: AccountPagePositionSection[]; positionSections: AccountPagePositionSection[];
recentEvents: AccountPageRecentEvents; recentEvents: AccountPageRecentEvents;
recentOperations: AccountPageRecentOperations; recentOperations: AccountPageRecentOperations;
source?: InvestmentResourceMeta;
}; };
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => { const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
@@ -49,6 +52,12 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Stack gap="lg"> <Stack gap="lg">
<AccountHeading account={props.account} accountId={props.accountId} /> <AccountHeading account={props.account} accountId={props.accountId} />
<SourceSyncStatus
lastAttempt={props.source?.lastAttempt}
lastSuccessfulAt={props.source?.lastSuccessfulAt}
stale={props.source?.freshness === "stale"}
/>
{props.partial {props.partial
? <Alert role="status">{t("investment.partial")}</Alert> ? <Alert role="status">{t("investment.partial")}</Alert>
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />} : <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
@@ -130,17 +130,6 @@ describe("AccountsPage", () => {
}); });
it("keeps the cached account list quietly after a refresh error", async () => { it("keeps the cached account list quietly after a refresh error", async () => {
const requests: unknown[] = [];
server.use(
http.post("*/api/v1/tbank-sync", async ({ request }) => {
requests.push(await request.json());
return HttpResponse.json(
{ code: "TBANK_UNAVAILABLE" },
{ status: 503 },
);
}),
);
let requestCount = 0; let requestCount = 0;
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => { http.get("*/api/v1/brokerage-accounts", () => {
@@ -169,7 +158,6 @@ describe("AccountsPage", () => {
expect(screen.queryByRole("button", { name: "Повторить" })) expect(screen.queryByRole("button", { name: "Повторить" }))
.not.toBeInTheDocument(); .not.toBeInTheDocument();
expect(screen.getByText(account.name)).toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument();
expect(requests).toEqual([]);
}); });
it("keeps a cached empty account list visible after a refresh error", async () => { it("keeps a cached empty account list visible after a refresh error", async () => {
@@ -201,14 +189,19 @@ describe("AccountsPage", () => {
}); });
}); });
it.each(["idle", "failed"] as const)( it.each(["none", "failed"] as const)(
"keeps partial empty membership distinct from no accounts (sync=%s)", "keeps incomplete empty membership distinct from no accounts (attempt=%s)",
async (state) => { async (state) => {
server.use( server.use(
http.get("*/api/v1/brokerage-accounts", () => http.get("*/api/v1/brokerage-accounts", () =>
HttpResponse.json(resource([], { HttpResponse.json(resource([], {
availability: "partial", completeness: "incomplete",
sync: { errorCode: null, id: null, state }, lastAttempt: {
finishedAt: state === "failed" ? "2026-08-14T00:01:00Z" : null,
problems: [],
startedAt: state === "none" ? null : "2026-08-14T00:00:00Z",
state,
},
})), })),
), ),
); );
@@ -217,7 +210,7 @@ it.each(["idle", "failed"] as const)(
await waitFor(() => await waitFor(() =>
expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({ expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject({
data: [], data: [],
meta: { availability: "partial" }, meta: { completeness: "incomplete" },
}), }),
); );
expect(screen.queryByText("Нет инвестиционных счетов")) expect(screen.queryByText("Нет инвестиционных счетов"))
@@ -15,6 +15,7 @@ const AccountsPage: FC = () => {
return ( return (
<AccountsPageLoaded <AccountsPageLoaded
accounts={viewModel.accounts} accounts={viewModel.accounts}
meta={viewModel.meta}
/> />
); );
}; };
@@ -8,7 +8,7 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
import { useInitialAvailability } from "@/shared/lib"; import { useInitialAvailability } from "@/shared/lib";
type AccountsPageViewModel type AccountsPageViewModel
= | { accounts: BrokerageAccountCard[]; state: "loaded" } = | { accounts: BrokerageAccountCard[]; meta: NonNullable<ReturnType<typeof useBrokerageAccounts>["meta"]>; state: "loaded" }
| { code?: string; state: "error" } | { code?: string; state: "error" }
| { state: "empty" } | { state: "empty" }
| { state: "loading" }; | { state: "loading" };
@@ -26,12 +26,13 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
const isAccessFailure = isAccountsAccessFailure(code); const isAccessFailure = isAccountsAccessFailure(code);
const hasResult = !!accounts.data const hasResult = !!accounts.data
&& !(accounts.meta?.availability === "partial" && accounts.data.length === 0); && !!accounts.meta
&& !(accounts.meta.completeness === "incomplete" && accounts.data.length === 0);
const availability = useInitialAvailability({ const availability = useInitialAvailability({
hasContent: hasResult, hasContent: hasResult,
identity: "accounts", identity: "accounts",
isError: accounts.isError || accounts.meta?.sync.state === "failed", isError: accounts.isError || accounts.meta?.lastAttempt.state === "failed",
isFetching: accounts.isFetching, isFetching: accounts.isFetching,
}); });
@@ -47,8 +48,12 @@ function useAccountsPageViewModel(): AccountsPageViewModel {
state: "empty" as const, state: "empty" as const,
}; };
const meta = accounts.meta;
if (!meta) return { state: "loading" as const };
return { return {
accounts: brokerageAccountCards(accounts.data, accounts.meta?.dependencies), accounts: brokerageAccountCards(accounts.data, meta.dependencies),
meta,
state: "loaded" as const, state: "loaded" as const,
}; };
} }
@@ -4,13 +4,17 @@ import { Button, Stack, Text, Title } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router"; import { Link } from "react-router";
import type { InvestmentResourceMeta } from "@/shared/api";
import { import {
type BrokerageAccountCard, type BrokerageAccountCard,
BrokerageAccountList, BrokerageAccountList,
} from "@/entities/brokerage-account"; } from "@/entities/brokerage-account";
import { SourceSyncStatus } from "@/shared/ui";
type AccountsPageLoadedProps = { type AccountsPageLoadedProps = {
accounts: BrokerageAccountCard[]; accounts: BrokerageAccountCard[];
meta: InvestmentResourceMeta;
}; };
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => { const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
@@ -24,6 +28,12 @@ const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
<Text c="dimmed" size="sm"> <Text c="dimmed" size="sm">
{t("accounts.subtitle")} {t("accounts.subtitle")}
</Text> </Text>
<SourceSyncStatus
lastAttempt={props.meta.lastAttempt}
lastSuccessfulAt={props.meta.lastSuccessfulAt}
stale={props.meta.freshness === "stale"}
/>
</Stack> </Stack>
<Button <Button
+3 -3
View File
@@ -17,7 +17,7 @@ function useBondsPageVm() {
code: apiErrorCode(result.error), code: apiErrorCode(result.error),
error: error:
!result.data !result.data
&& (result.isError || result.meta?.sync.state === "failed"), && (result.isError || result.meta?.lastAttempt.state === "failed"),
items: items:
result.data?.map(bond => ({ result.data?.map(bond => ({
...bond, ...bond,
@@ -29,9 +29,9 @@ function useBondsPageVm() {
&& (isWaiting && (isWaiting
|| (!result.data || (!result.data
&& !result.isError && !result.isError
&& result.meta?.sync.state !== "failed")), && result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange, onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial", partial: result.meta?.completeness === "incomplete",
query, query,
ready: ready:
debounced.length >= MIN_BOND_SEARCH_LENGTH debounced.length >= MIN_BOND_SEARCH_LENGTH
@@ -1,16 +1,28 @@
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw"; import { http, HttpResponse } from "msw";
import { MemoryRouter } from "react-router"; import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { configureAuthClient } from "@/shared/api"; import { configureAuthClient } from "@/shared/api";
import { createTestQueryClient } from "@/test/query"; import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server"; import { server } from "@/test/server";
import { StocksPage } from "./StocksPage"; import { StocksPage } from "./StocksPage";
import { useStocksPageVm } from "./StocksPage.vm";
const StocksPageVmProbe = () => {
const vm = useStocksPageVm();
return (
<>
<button onClick={vm.onSearch}>search</button>
<button onClick={() => vm.onPage(2)}>page</button>
<output>{vm.items.map(item => item.name).join(", ")}</output>
</>
);
};
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
@@ -18,27 +30,28 @@ describe("stock search", () => {
it("waits for a meaningful query and uses the server-verified ticker link", async () => { it("waits for a meaningful query and uses the server-verified ticker link", async () => {
const query = vi.fn(); const query = vi.fn();
server.use( server.use(
http.get("*/api/v1/stocks", ({ request }) => { http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(new URL(request.url).searchParams.get("query")); query(await request.json());
return HttpResponse.json( return HttpResponse.json({
resource([ asOf: "2026-10-10T10:00:00Z",
{ hasNext: false,
apiTradeAvailable: true, items: [{
classCode: "TQBR", countryOfRisk: "RU",
instrumentUid: "unique-stock", currency: "RUB",
isin: "RU0009029540", instrumentUid: "e6123145-9665-43e0-8413-cd61b8aa9b13",
logoName: "sber.png", isin: "RU0009029540",
name: "Сбербанк", logoName: "sber.png",
price: { currency: "RUB", value: "306.12" }, lot: 10,
priceAt: "2026-09-10T10:00:00Z", name: "Сбербанк",
realExchange: "REAL_EXCHANGE_MOEX", realExchange: "REAL_EXCHANGE_MOEX",
shareType: "SHARE_TYPE_COMMON", sector: "financial",
slug: "SBER", shareType: "SHARE_TYPE_COMMON",
ticker: "SBER", ticker: "SBER",
}, }],
]), page: 1,
); searchId: "search-id",
});
}), }),
); );
configureAuthClient({ configureAuthClient({
@@ -46,7 +59,6 @@ describe("stock search", () => {
clear: vi.fn(), clear: vi.fn(),
refresh: () => Promise.resolve(false), refresh: () => Promise.resolve(false),
}); });
vi.useFakeTimers();
render( render(
<QueryClientProvider client={createTestQueryClient()}> <QueryClientProvider client={createTestQueryClient()}>
<MantineProvider> <MantineProvider>
@@ -56,23 +68,212 @@ describe("stock search", () => {
</MantineProvider> </MantineProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
const input = screen.getByLabelText("Поиск акций"); const input = screen.getByLabelText("Название или тикер");
fireEvent.change(input, { target: { value: "S" } });
await act(() => vi.advanceTimersByTimeAsync(300));
expect(query).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: "SB" } });
await act(() => vi.advanceTimersByTimeAsync(299));
expect(query).not.toHaveBeenCalled();
// The final input uses real timers for network and query notifications.
vi.useRealTimers();
fireEvent.change(input, { target: { value: "SBER" } }); fireEvent.change(input, { target: { value: "SBER" } });
expect(query).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
expect( expect(
await screen.findByRole("link", { name: /Сбербанк/ }), await screen.findByRole("link", { name: /Сбербанк/ }),
).toHaveAttribute("href", "/stocks/SBER"); ).toHaveAttribute("href", "/stocks/e6123145-9665-43e0-8413-cd61b8aa9b13");
expect(screen.getByText("306,12 ₽")).toBeVisible(); expect(screen.getByText("Список получен 10.10.2026, 10:00")).toBeVisible();
expect(screen.getByText("Режим: TQBR")).toBeVisible(); expect(screen.getByText("Лот: 10 шт.")).toBeVisible();
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
expect(screen.getByText("Мосбиржа")).toBeVisible(); expect(screen.getAllByText("Мосбиржа")[0]).toBeVisible();
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith("SBER")); expect(screen.getByAltText("")).toHaveAttribute(
"src",
"https://invest-brands.cdn-tinkoff.ru/sberx160.png",
);
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({ query: "SBER" }));
});
it("searches by the selected sector without a text query", async () => {
const query = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", async ({ request }) => {
query(await request.json());
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "sector-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("combobox", { name: "Сектор" }));
fireEvent.click(await screen.findByRole("option", { name: "Энергетика" }));
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(query).toHaveBeenCalledExactlyOnceWith({
sector: "energy",
}));
});
it("shows known result pages and reads the selected page", async () => {
const requestedPages = vi.fn();
server.use(
http.post("*/api/v1/stocks/screener/search", () => HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: true,
items: [],
page: 1,
searchId: "paged-search",
})),
http.get("*/api/v1/stocks/screener/search/paged-search", ({ request }) => {
const page = Number(new URL(request.url).searchParams.get("page"));
requestedPages(page);
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page,
searchId: "paged-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
fireEvent.click(await screen.findByRole(
"button",
{ name: "Страница 2" },
));
await waitFor(() =>
expect(requestedPages).toHaveBeenCalledExactlyOnceWith(2),
);
});
it("keeps a newer search when an earlier page request finishes late", async () => {
const oldPage = Promise.withResolvers<void>();
let searches = 0;
server.use(
http.post("*/api/v1/stocks/screener/search", () => {
searches += 1;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: searches === 1,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: searches === 1 ? "old" : "new",
isin: null,
logoName: null,
lot: 1,
name: searches === 1 ? "Старый" : "Новый",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: searches === 1 ? "OLD" : "NEW",
}],
page: 1,
searchId: searches === 1 ? "old-search" : "new-search",
});
}),
http.get("*/api/v1/stocks/screener/search/old-search", async () => {
await oldPage.promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [{
countryOfRisk: "RU",
currency: "RUB",
instrumentUid: "old-page",
isin: null,
logoName: null,
lot: 1,
name: "Старый результат",
realExchange: "REAL_EXCHANGE_MOEX",
sector: "financial",
shareType: "SHARE_TYPE_COMMON",
ticker: "OLD",
}],
page: 2,
searchId: "old-search",
});
}),
);
render(<StocksPageVmProbe />);
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Старый");
fireEvent.click(screen.getByRole("button", { name: "page" }));
fireEvent.click(screen.getByRole("button", { name: "search" }));
await screen.findByText("Новый");
oldPage.resolve();
await waitFor(() => expect(screen.getByText("Новый")).toBeVisible());
expect(screen.queryByText("Старый результат")).not.toBeInTheDocument();
});
it("disables filters and shows loading while searching", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
server.use(
http.post("*/api/v1/stocks/screener/search", async () => {
await promise;
return HttpResponse.json({
asOf: "2026-10-10T10:00:00Z",
hasNext: false,
items: [],
page: 1,
searchId: "pending-search",
});
}),
);
configureAuthClient({
accessToken: () => "synthetic",
clear: vi.fn(),
refresh: () => Promise.resolve(false),
});
render(
<QueryClientProvider client={createTestQueryClient()}>
<MantineProvider>
<MemoryRouter>
<StocksPage />
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Найти" }));
await waitFor(() => expect(
screen.getByRole("combobox", { name: "Сектор" }),
).toBeDisabled());
expect(screen.getByRole("button", { name: "Найти" })).toHaveAttribute(
"data-loading",
"true",
);
resolve();
}); });
}); });
@@ -1,89 +1,274 @@
import type { FC } from "react"; import type { ChangeEvent, FC } from "react";
import { Badge, Group, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { import {
InstrumentSearch, Alert,
InstrumentSearchCard, Anchor,
} from "@/features/instrument-search"; Avatar,
import { formatMoney, formatSnapshotTime } from "@/shared/lib"; Badge,
Button,
Card,
Fieldset,
Group,
NumberInput,
Pagination,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { StocksPageVm } from "./StocksPage.vm"; import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
type CriteriaField = {
label: keyof Criteria;
options?: readonly string[];
type?: "number";
};
type StockCardProps = { item: StocksPageVm["items"][number] };
type StockCriteriaFieldProps = {
criteria: Criteria;
field: CriteriaField;
onChange: (name: keyof Criteria, value: string) => void;
};
type StocksPageViewProps = { vm: StocksPageVm }; type StocksPageViewProps = { vm: StocksPageVm };
const criteriaFields: CriteriaField[] = [
{ label: "query" },
{
label: "sector",
options: [
"consumer",
"energy",
"financial",
"health_care",
"industrials",
"it",
"materials",
"real_estate",
"telecom",
"utilities",
],
},
{
label: "shareType",
options: [
"SHARE_TYPE_ADR",
"SHARE_TYPE_COMMON",
"SHARE_TYPE_GDR",
"SHARE_TYPE_PREFERRED",
],
},
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
{
label: "realExchange",
options: [
"REAL_EXCHANGE_MOEX",
"REAL_EXCHANGE_RTS",
"REAL_EXCHANGE_OTC",
"REAL_EXCHANGE_DEALER",
],
},
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
{ label: "maxLot", type: "number" },
];
function numberChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: number | string) => onChange(field, String(value));
}
function selectChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (value: null | string) => onChange(field, value ?? "");
}
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
return field.options?.map(value => ({
label: translate(`stock.screener.options.${field.label}.${value}`, {
defaultValue: value,
}),
value,
}));
}
function textChange(
field: keyof Criteria,
onChange: StockCriteriaFieldProps["onChange"],
) {
return (event: ChangeEvent<HTMLInputElement>) =>
onChange(field, event.currentTarget.value);
}
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
const { criteria, field, onChange } = props;
const { t } = useTranslation();
const options = selectOptions(field, t);
if (field.type === "number") return (
<NumberInput
label={t(`stock.screener.${field.label}`)}
min={1}
onChange={numberChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
if (options) return (
<Select
clearable
data={options}
label={t(`stock.screener.${field.label}`)}
onChange={selectChange(field.label, onChange)}
value={criteria[field.label] || null}
/>
);
return (
<TextInput
label={t(`stock.screener.${field.label}`)}
onChange={textChange(field.label, onChange)}
value={criteria[field.label]}
/>
);
};
const StockCard: FC<StockCardProps> = (props) => {
const { item } = props;
const { t } = useTranslation();
const exchange = item.realExchange
? t(`stock.exchanges.${item.realExchange}`, {
defaultValue: item.realExchange,
})
: undefined;
const lot = item.lot === null
? undefined
: t("stock.lot", { value: item.lot });
return (
<Card p="md" withBorder>
<Group gap="sm" wrap="nowrap">
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
{item.name[0]}
</Avatar>
<Stack gap={2} miw={0}>
<Anchor
c="inherit"
component={Link}
fw={600}
to={`/stocks/${item.instrumentUid}`}
>
{item.name}
</Anchor>
<Text c="dimmed" size="xs">
{item.ticker ?? "-"} · {item.shareType ?? "-"}
</Text>
<Group gap="xs">
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
{item.currency && (
<Badge color="gray" variant="light">{item.currency}</Badge>
)}
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
</Group>
{item.isin && (
<Text c="dimmed" size="xs">
{t("stock.isin", { value: item.isin })}
</Text>
)}
</Stack>
</Group>
</Card>
);
};
const StocksPageView: FC<StocksPageViewProps> = (props) => { const StocksPageView: FC<StocksPageViewProps> = (props) => {
const { vm } = props; const { vm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
return ( return (
<InstrumentSearch kind="stock" vm={vm}> <Stack gap="lg">
{vm.items.map((stock) => { <div>
const quote = ( <Title order={1}>{t("stock.screenerTitle")}</Title>
<>
<Text fw={650} size="xl">
{stock.price ? formatMoney(stock.price) : "—"}
</Text>
<Text c="dimmed" size="xs"> <Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
{stock.price && stock.priceAt </div>
? t("stock.priceAt", {
date: formatSnapshotTime(stock.priceAt),
})
: t("stock.priceUnavailable")}
</Text>
{stock.apiTradeAvailable === true && ( <Fieldset disabled={vm.loading} variant="unstyled">
<Text c="green.8" mt="sm" size="xs"> <SimpleGrid cols={{ base: 1, sm: 2 }}>
{t("stock.apiAvailable")} {criteriaFields.map(field => (
</Text> <StockCriteriaField
)} criteria={vm.criteria}
</> field={field}
); key={field.label}
onChange={vm.onCriteriaChange}
/>
))}
</SimpleGrid>
return ( <Group mt="lg">
<InstrumentSearchCard <Button loading={vm.loading} onClick={vm.onSearch}>
instrument={stock} {t("stock.screener.search")}
key={stock.instrumentUid} </Button>
quote={quote} </Group>
> </Fieldset>
<Text c="dimmed" size="sm">
{stock.ticker} ·{" "}
{t(`stock.shareTypes.${stock.shareType}`, {
defaultValue: t("stock.share"),
})}
</Text>
<Group gap="xs" mt="sm"> {!!vm.error && (
<Badge color="gray" variant="light"> <Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
{t(`stock.exchanges.${stock.realExchange}`, { )}
defaultValue: t("stock.exchangeUnknown"),
})}
</Badge>
{!!stock.classCode && ( {vm.resultTime && (
<Text c="dimmed" size="xs"> <Text c="dimmed" size="sm">
{t("stock.classCode", { code: stock.classCode })} {t("stock.screener.asOf", {
</Text> date: formatSnapshotTime(vm.resultTime),
)} })}
</Group> </Text>
)}
{!!stock.isin && ( {vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
<Text c="dimmed" mt={4} size="xs"> <Text c="dimmed">{t("stock.screener.empty")}</Text>
{t("stock.isin", { value: stock.isin })} )}
</Text>
)}
{stock.apiTradeAvailable === false && ( <Stack gap="sm">
<Text c="dimmed" mt={4} size="xs"> {vm.items.map(item => (
{t("stock.apiUnavailable")} <StockCard item={item} key={item.instrumentUid} />
</Text> ))}
)} </Stack>
</InstrumentSearchCard>
); {vm.totalPages > 1 && (
})} <Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
</InstrumentSearch> <Pagination
disabled={vm.loading}
getControlProps={getControlProps}
getItemProps={getItemProps}
onChange={vm.onPage}
total={vm.totalPages}
value={vm.page ?? 1}
/>
</Group>
)}
</Stack>
); );
}; };
+110 -34
View File
@@ -1,43 +1,119 @@
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock"; import { useRef, useState } from "react";
import { useInstrumentSearch } from "@/features/instrument-search";
import { useScreenSync } from "@/features/tbank-connection"; import { apiClient } from "@/shared/api";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib"; type Criteria = {
countryOfRisk: string;
currency: string;
maxLot: string;
query: string;
realExchange: string;
sector: string;
shareType: string;
};
type ScreenerPage = Awaited<ReturnType<typeof readPage>>;
type StocksPageVm = ReturnType<typeof useStocksPageVm>; type StocksPageVm = ReturnType<typeof useStocksPageVm>;
const emptyCriteria: Criteria = {
countryOfRisk: "",
currency: "",
maxLot: "",
query: "",
realExchange: "",
sector: "",
shareType: "",
};
async function createSearch(criteria: Criteria) {
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
body: {
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
...(criteria.currency && { currency: criteria.currency }),
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
...(criteria.query && { query: criteria.query }),
...(criteria.realExchange && { realExchange: criteria.realExchange }),
...(criteria.sector && { sector: criteria.sector }),
...(criteria.shareType && { shareType: criteria.shareType }),
},
});
if (!data) throw new Error("Stock screener search failed");
return data;
}
async function readPage(searchId: string, page: number) {
const { data } = await apiClient.GET(
"/api/v1/stocks/screener/search/{searchId}",
{ params: { path: { searchId }, query: { page } } },
);
if (!data) throw new Error("Stock screener page failed");
return data;
}
function useStocksPageVm() { function useStocksPageVm() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH); const [criteria, setCriteria] = useState(emptyCriteria);
const { canSearch, debounced, isWaiting, query } = search; const [page, setPage] = useState<null | ScreenerPage>(null);
const result = useStockSearch(debounced); const [pending, setPending] = useState(false);
useScreenSync({ query: debounced, screen: "stocks" }, [result]); const [error, setError] = useState(false);
const requestIdRef = useRef(0);
const search = async () => {
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await createSearch(criteria);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
const changePage = async (nextPage: number) => {
if (!page) return;
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await readPage(page.searchId, nextPage);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
return { return {
canSearch, criteria,
code: apiErrorCode(result.error), error,
error: items: page?.items ?? [],
!result.data loading: pending,
&& (result.isError || result.meta?.sync.state === "failed"), onCriteriaChange: (name: keyof Criteria, value: string) =>
items: setCriteria(current => ({ ...current, [name]: value })),
result.data?.map(stock => ({ onPage: (nextPage: number) => void changePage(nextPage),
...stock, onSearch: () => void search(),
logoSrc: instrumentLogoUrl(stock.logoName), page: page?.page,
to: `/stocks/${encodeURIComponent(stock.slug)}`, resultTime: page?.asOf,
})) ?? [], searched: page !== null,
loading: totalPages: page ? page.page + Number(page.hasNext) : 0,
canSearch
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.sync.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.availability === "partial",
query,
ready:
debounced.length >= MIN_STOCK_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
}; };
} }
export { type StocksPageVm, useStocksPageVm }; export { type Criteria, type StocksPageVm, useStocksPageVm };
@@ -0,0 +1,20 @@
import type { Pagination } from "@mantine/core";
import type { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
function useStocksPagePresentationVm() {
const { t } = useTranslation();
const getControlProps: ComponentProps<typeof Pagination>["getControlProps"] = control => ({
"aria-label": t(control === "previous" ? "stock.previous" : "stock.next"),
});
const getItemProps: ComponentProps<typeof Pagination>["getItemProps"] = page => ({
"aria-label": t("stock.screener.page", { page }),
});
return { getControlProps, getItemProps };
}
export { useStocksPagePresentationVm };
+268 -138
View File
@@ -436,6 +436,38 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/stocks/screener/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["createStockScreenerSearch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/screener/search/{searchId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["readStockScreenerPage"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/stocks/{uid}": { "/api/v1/stocks/{uid}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -676,22 +708,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/tbank-sync": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["TbankSyncController_request"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tbank-sync/{id}": { "/api/v1/tbank-sync/{id}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -725,7 +741,10 @@ export interface components {
* } * }
* ] * ]
*/ */
errors?: string[]; errors?: {
pointer: string;
detail: string;
}[];
}; };
CredentialsDto: { CredentialsDto: {
/** @example user@example.com */ /** @example user@example.com */
@@ -912,24 +931,35 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
historyState: "missing" | "loading" | "partial" | "complete" | "failed"; historyState: "missing" | "loading" | "partial" | "complete" | "failed";
}; };
InvestmentSyncDto: { InvestmentAttemptProblemDto: {
id: string | null;
/** @enum {string} */ /** @enum {string} */
state: "idle" | "running" | "failed"; section: "membership" | "portfolio" | "operations" | "events";
accountId: string | null;
errorCode: string | null; errorCode: string | null;
}; };
InvestmentAttemptDto: {
/** @enum {string} */
state: "none" | "running" | "succeeded" | "partial" | "failed";
/** Format: date-time */
startedAt: string | null;
/** Format: date-time */
finishedAt: string | null;
problems: components["schemas"]["InvestmentAttemptProblemDto"][];
};
InvestmentDependencyDto: { InvestmentDependencyDto: {
dataRevision: string; dataRevision: string;
unavailableReason?: string; unavailableReason?: string;
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
}; };
InvestmentResourceMetaDto: { InvestmentResourceMetaDto: {
dataRevision: string; dataRevision: string;
@@ -937,17 +967,33 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
dependencies?: components["schemas"]["InvestmentDependencyDto"][]; dependencies?: components["schemas"]["InvestmentDependencyDto"][];
}; };
HourlySyncStatusDto: {
/**
* @description Last hourly walk state. Cancelled means an interrupted attempt; its start still delays the next term.
* @enum {string}
*/
state: "running" | "succeeded" | "partial" | "failed" | "cancelled";
/** Format: date-time */
startedAt: string;
/** Format: date-time */
finishedAt: string | null;
problems: components["schemas"]["InvestmentAttemptProblemDto"][];
};
BrokerageAccountsResourceDto: { BrokerageAccountsResourceDto: {
data: components["schemas"]["BrokerageAccountDto"][] | null; data: components["schemas"]["BrokerageAccountDto"][] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
/** @description Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet. */
sync: components["schemas"]["HourlySyncStatusDto"] | null;
}; };
AccountReferenceDto: { AccountReferenceDto: {
id: string; id: string;
@@ -1128,12 +1174,14 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @enum {string} */ /** @enum {string} */
role: "selected" | "previous" | "year"; role: "selected" | "previous" | "year";
}; };
@@ -1143,12 +1191,14 @@ export interface components {
coverage?: components["schemas"]["InvestmentCoverageDto"]; coverage?: components["schemas"]["InvestmentCoverageDto"];
resourceKey: string; resourceKey: string;
/** @enum {string} */ /** @enum {string} */
availability: "missing" | "partial" | "complete"; availability: "available" | "initial-loading" | "temporarily-unavailable" | "connection-unavailable";
/** @enum {string} */
completeness: "complete" | "incomplete";
/** @enum {string} */ /** @enum {string} */
freshness: "unknown" | "fresh" | "stale"; freshness: "unknown" | "fresh" | "stale";
/** Format: date-time */ /** Format: date-time */
lastSuccessfulAt: string | null; lastSuccessfulAt: string | null;
sync: components["schemas"]["InvestmentSyncDto"]; lastAttempt: components["schemas"]["InvestmentAttemptDto"];
/** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */ /** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */
dependencies: components["schemas"]["IncomeDependencyDto"][]; dependencies: components["schemas"]["IncomeDependencyDto"][];
}; };
@@ -1232,6 +1282,37 @@ export interface components {
data: components["schemas"]["StockDto"] | null; data: components["schemas"]["StockDto"] | null;
meta: components["schemas"]["InvestmentResourceMetaDto"]; meta: components["schemas"]["InvestmentResourceMetaDto"];
}; };
StockScreenerCriteriaDto: {
query?: string;
sector?: string;
shareType?: string;
currency?: string;
realExchange?: string;
countryOfRisk?: string;
maxLot?: number;
};
StockScreenerItemDto: {
/** Format: uuid */
instrumentUid: string;
name: string;
ticker: string | null;
isin: string | null;
logoName: string | null;
shareType: string | null;
sector: string | null;
currency: string | null;
realExchange: string | null;
countryOfRisk: string | null;
lot: number | null;
};
StockScreenerPageDto: {
searchId: string;
/** Format: date-time */
asOf: string;
page: number;
hasNext: boolean;
items: components["schemas"]["StockScreenerItemDto"][];
};
StockQuoteDto: { StockQuoteDto: {
price: components["schemas"]["MoneyDto"] | null; price: components["schemas"]["MoneyDto"] | null;
/** Format: date-time */ /** Format: date-time */
@@ -1543,24 +1624,6 @@ export interface components {
PutTbankConnectionDto: { PutTbankConnectionDto: {
token: string; token: string;
}; };
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
EventsInvestmentScreenDto: { EventsInvestmentScreenDto: {
accountId: string; accountId: string;
/** /**
@@ -1579,6 +1642,24 @@ export interface components {
*/ */
to?: string; to?: string;
}; };
IncomeInvestmentScreenDto: {
accountId: string;
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
screen: "income";
/**
* Format: date
* @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.
*/
from?: string;
/**
* Format: date
* @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now.
*/
to?: string;
};
SyncCandleRangeDto: { SyncCandleRangeDto: {
/** Format: date-time */ /** Format: date-time */
from: string; from: string;
@@ -1611,13 +1692,6 @@ export interface components {
*/ */
to?: string; to?: string;
}; };
TbankSyncAcceptedDto: {
/** Format: uuid */
id: string;
scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
/** Format: date-time */
now: string;
};
TbankSyncStatusDto: { TbankSyncStatusDto: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -3175,9 +3249,7 @@ export interface operations {
}; };
BrokerageAccountsController_list: { BrokerageAccountsController_list: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -3241,9 +3313,7 @@ export interface operations {
}; };
PortfolioController_all: { PortfolioController_all: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
@@ -3307,9 +3377,7 @@ export interface operations {
}; };
PortfolioController_get: { PortfolioController_get: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
accountId: string; accountId: string;
@@ -3376,7 +3444,6 @@ export interface operations {
BrokerageOperationsController_all: { BrokerageOperationsController_all: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */ /** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string; instrumentUid?: string;
from?: string; from?: string;
@@ -3449,7 +3516,6 @@ export interface operations {
BrokerageOperationsController_get: { BrokerageOperationsController_get: {
parameters: { parameters: {
query?: { query?: {
sync?: "observe";
/** @description Instrument UID. Without from, includes history since the earliest available account opening. */ /** @description Instrument UID. Without from, includes history since the earliest available account opening. */
instrumentUid?: string; instrumentUid?: string;
from?: string; from?: string;
@@ -4074,6 +4140,142 @@ export interface operations {
}; };
}; };
}; };
createStockScreenerSearch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["StockScreenerCriteriaDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockScreenerPageDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
readStockScreenerPage: {
parameters: {
query: {
page: number;
};
header?: never;
path: {
searchId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StockScreenerPageDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
StockController_get: { StockController_get: {
parameters: { parameters: {
query?: { query?: {
@@ -4281,9 +4483,7 @@ export interface operations {
}; };
StockController_position: { StockController_position: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -4893,9 +5093,7 @@ export interface operations {
}; };
BondController_position: { BondController_position: {
parameters: { parameters: {
query?: { query?: never;
sync?: "observe";
};
header?: never; header?: never;
path: { path: {
uid: string; uid: string;
@@ -5221,74 +5419,6 @@ export interface operations {
}; };
}; };
}; };
TbankSyncController_request: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"];
};
};
responses: {
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TbankSyncAcceptedDto"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Authentication required */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Resource not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
/** @description Upstream service unavailable */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetailsDto"];
};
};
};
};
TbankSyncController_status: { TbankSyncController_status: {
parameters: { parameters: {
query?: never; query?: never;
-2
View File
@@ -1,5 +1,4 @@
export { export {
useAuthenticatedQueries,
useAuthenticatedQuery, useAuthenticatedQuery,
} from "./authenticated-query"; } from "./authenticated-query";
export { export {
@@ -11,7 +10,6 @@ export type { paths } from "./generated/schema";
export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
export { export {
invalidateResourceAccess,
type InvestmentResourceMeta, type InvestmentResourceMeta,
useResourceQuery, useResourceQuery,
} from "./resource-query"; } from "./resource-query";
+8 -5
View File
@@ -75,9 +75,10 @@ describe("persistent resource query lifecycle", () => {
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json( HttpResponse.json(
resource(null, { resource(null, {
sync: { lastAttempt: {
errorCode: "TBANK_UNAVAILABLE", finishedAt: "2026-08-14T00:01:00Z",
id: "work", problems: [],
startedAt: "2026-08-14T00:00:00Z",
state: "failed", state: "failed",
}, },
}), }),
@@ -89,14 +90,16 @@ describe("persistent resource query lifecycle", () => {
wrapper: createQueryWrapper().wrapper, wrapper: createQueryWrapper().wrapper,
}); });
await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed")); await waitFor(() =>
expect(result.current.meta?.lastAttempt.state).toBe("failed"),
);
expect(result.current.data).toBeUndefined(); expect(result.current.data).toBeUndefined();
expect(result.current.isSuccess).toBe(true); expect(result.current.isSuccess).toBe(true);
expect(result.current.status).toBe("success"); expect(result.current.status).toBe("success");
expect(result.current.isPending).toBe(false); expect(result.current.isPending).toBe(false);
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
expect(result.current.meta?.availability).toBe("missing"); expect(result.current.meta?.availability).toBe("initial-loading");
}); });
it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { it("keeps the refetch envelope while exposing the refreshed resource payload", async () => {
auth(); auth();
+14 -4
View File
@@ -15,8 +15,10 @@ import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details";
type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"]; type InvestmentResourceMeta = components["schemas"]["InvestmentResourceMetaDto"];
type Resource = { type Resource = {
[key: string]: unknown;
data: unknown; data: unknown;
meta: InvestmentResourceMeta; meta: InvestmentResourceMeta;
sync?: unknown;
}; };
type ResourcePath = { type ResourcePath = {
@@ -66,8 +68,12 @@ function invalidateResourceAccess(
// Keep endpoint and parameters intact. The generated queryFn accepts the // Keep endpoint and parameters intact. The generated queryFn accepts the
// optional sync mode even for a cache key originally created without init. // optional sync mode even for a cache key originally created without init.
function observationKey<K extends ResourceQueryKey>(key: K): K { function observationKey<K extends readonly unknown[]>(key: K): K {
const [method, path, init] = key; const [method, path, init] = key as unknown as [
string,
string,
{ params?: { path?: object; query?: object } }?,
];
return [method, path, { return [method, path, {
...init, ...init,
@@ -76,7 +82,11 @@ function observationKey<K extends ResourceQueryKey>(key: K): K {
} }
// Observation changes transport mode, never the generated cache identity. // Observation changes transport mode, never the generated cache identity.
function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>( function useResourceQuery<
T extends Resource,
E = unknown,
K extends readonly unknown[] = ResourceQueryKey,
>(
options: UseQueryOptions<T, E, T, K>, options: UseQueryOptions<T, E, T, K>,
) { ) {
const client = useQueryClient(); const client = useQueryClient();
@@ -135,11 +145,11 @@ function useResourceQuery<T extends Resource, E, K extends ResourceQueryKey>(
queryKey: options.queryKey, queryKey: options.queryKey,
refetch: query.refetch, refetch: query.refetch,
status: query.status, status: query.status,
sync: query.data && "sync" in query.data ? query.data.sync : undefined,
}; };
} }
export { export {
invalidateResourceAccess,
type InvestmentResourceMeta, type InvestmentResourceMeta,
useResourceQuery, useResourceQuery,
}; };
File diff suppressed because it is too large Load Diff
+58
View File
@@ -382,6 +382,13 @@ const ru = {
yearUnavailable: "Сравнение с годом ранее недоступно.", yearUnavailable: "Сравнение с годом ранее недоступно.",
}, },
investment: { investment: {
attempt: {
cancelled: "Обновление отменено",
failed: "Обновление не удалось",
partial: "Обновлено частично",
running: "Обновление выполняется",
succeeded: "Обновлено",
},
history: "История догружается", history: "История догружается",
partial: "Период загружен не полностью", partial: "Период загружен не полностью",
refresh: "Обновить", refresh: "Обновить",
@@ -629,6 +636,57 @@ const ru = {
quantity: "Количество акций", quantity: "Количество акций",
record: "Дата реестра", record: "Дата реестра",
refreshPrice: "Обновить цену", refreshPrice: "Обновить цену",
screener: {
asOf: "Список получен {{date}}",
countryOfRisk: "Страна риска",
currency: "Валюта расчётов",
empty: "Подходящие акции не найдены.",
error: "Не удалось получить результат скринера. Попробуйте найти снова.",
maxLot: "Максимальный размер лота",
options: {
countryOfRisk: {
CN: "Китай",
HK: "Гонконг",
KZ: "Казахстан",
RU: "Россия",
TR: "Турция",
US: "США",
},
realExchange: {
REAL_EXCHANGE_DEALER: "Площадка брокера",
REAL_EXCHANGE_MOEX: "Мосбиржа",
REAL_EXCHANGE_OTC: "Внебиржевой рынок",
REAL_EXCHANGE_RTS: "СПБ Биржа",
},
sector: {
consumer: "Потребительский сектор",
energy: "Энергетика",
financial: "Финансы",
health_care: "Здравоохранение",
industrials: "Промышленность",
it: "Информационные технологии",
materials: "Материалы",
real_estate: "Недвижимость",
telecom: "Телекоммуникации",
utilities: "Коммунальные услуги",
},
shareType: {
SHARE_TYPE_ADR: "Депозитарная расписка",
SHARE_TYPE_COMMON: "Обыкновенная акция",
SHARE_TYPE_GDR: "Глобальная депозитарная расписка",
SHARE_TYPE_PREFERRED: "Привилегированная акция",
},
},
page: "Страница {{page}}",
pagination: "Страницы результатов",
query: "Название или тикер",
realExchange: "Площадка",
search: "Найти",
sector: "Сектор",
shareType: "Тип акции",
},
screenerSubtitle: "Подберите акции по данным каталога T-Bank. Доступность не гарантирует исполнение сделки.",
screenerTitle: "Скринер акций",
searchHint: "Введите хотя бы два символа.", searchHint: "Введите хотя бы два символа.",
searching: "Ищем акции", searching: "Ищем акции",
searchLabel: "Поиск акций", searchLabel: "Поиск акций",
@@ -32,6 +32,21 @@ describe("SourceSyncStatus", () => {
expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось"); expect(screen.getByRole("status")).toHaveTextContent("Обновление не удалось");
}); });
it("announces an active or failed source attempt with text", () => {
renderStatus({
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: "2026-02-03T10:15:00.000Z",
state: "running",
},
});
expect(screen.getByRole("status")).toHaveTextContent(
"Обновление выполняется",
);
});
it("keeps the notices next to the timestamp they belong to", () => { it("keeps the notices next to the timestamp they belong to", () => {
renderStatus({ renderStatus({
errorText: "Обновление не удалось", errorText: "Обновление не удалось",
@@ -3,6 +3,8 @@ import type { FC } from "react";
import { Text } from "@mantine/core"; import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { InvestmentResourceMeta } from "@/shared/api";
import { formatSnapshotTime } from "@/shared/lib"; import { formatSnapshotTime } from "@/shared/lib";
// Owns the "when did the source last answer" line and the two notices that // Owns the "when did the source last answer" line and the two notices that
@@ -10,6 +12,7 @@ import { formatSnapshotTime } from "@/shared/lib";
// shows a muted line, a page header a progress bar — that is a product call. // shows a muted line, a page header a progress bar — that is a product call.
type SourceSyncStatusProps = { type SourceSyncStatusProps = {
errorText?: null | string; errorText?: null | string;
lastAttempt?: InvestmentResourceMeta["lastAttempt"];
lastSuccessfulAt?: null | string; lastSuccessfulAt?: null | string;
stale?: boolean; stale?: boolean;
}; };
@@ -17,6 +20,12 @@ type SourceSyncStatusProps = {
const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => { const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const attempt = props.lastAttempt;
const attemptText = attempt && attempt.state !== "none"
? t(`investment.attempt.${attempt.state}`)
: null;
return ( return (
<> <>
{!!props.lastSuccessfulAt && ( {!!props.lastSuccessfulAt && (
@@ -29,6 +38,12 @@ const SourceSyncStatus: FC<SourceSyncStatusProps> = (props) => {
{!!props.stale && <Text size="xs">{t("investment.stale")}</Text>} {!!props.stale && <Text size="xs">{t("investment.stale")}</Text>}
{!!attemptText && (
<Text role="status" size="xs">
{attemptText}
</Text>
)}
{!!props.errorText && <Text role="status">{props.errorText}</Text>} {!!props.errorText && <Text role="status">{props.errorText}</Text>}
</> </>
); );
+8 -2
View File
@@ -7,12 +7,18 @@ function resource<T>(data: T, meta: Partial<Meta> = {}) {
return { return {
data, data,
meta: { meta: {
availability: data === null ? "missing" : "complete", availability: data === null ? "initial-loading" : "available",
completeness: "complete",
dataRevision: "1", dataRevision: "1",
freshness: "fresh", freshness: "fresh",
lastAttempt: {
finishedAt: null,
problems: [],
startedAt: null,
state: "none",
},
lastSuccessfulAt: "2026-08-14T00:00:00Z", lastSuccessfulAt: "2026-08-14T00:00:00Z",
resourceKey: "fixture", resourceKey: "fixture",
sync: { errorCode: null, id: null, state: "idle" },
...meta, ...meta,
} satisfies Meta, } satisfies Meta,
}; };