440 lines
21 KiB
Markdown
440 lines
21 KiB
Markdown
# Broker Accounts Overview Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Превратить `/broker` в информативный обзор одного–трёх брокерских счетов с общей сводкой, независимой загрузкой карточек и устойчивыми состояниями ошибок.
|
||
|
||
**Architecture:** `BrokerAccountsPage` получает список счетов и передаёт его в новый hook на основе TanStack Query `useQueries`; query key совпадает с `useBrokerPortfolio`, поэтому детальная страница переиспользует кеш. Чистый модуль агрегации группирует денежные значения по валютам и рассчитывает единственный новый финансовый показатель по формуле из spec. UI разбит на общую сводку, карточку счёта и компактную полосу распределения; backend и OpenAPI не меняются согласно ADR-012.
|
||
|
||
**Tech Stack:** React 18, TypeScript, TanStack Query v5, React Router v6, Vitest, Testing Library, CSS custom properties.
|
||
|
||
---
|
||
|
||
## Карта файлов
|
||
|
||
- Create `apps/frontend/src/pages/broker/brokerAccountsOverview.ts` — чистые типы, агрегация валютных сумм и форматирование дат/денег/процентов.
|
||
- Create `apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts` — unit-тесты финансовой агрегации и edge cases.
|
||
- Create `apps/frontend/src/hooks/useBrokerAccountPortfolios.ts` — параллельные portfolio queries с общими query keys.
|
||
- Create `apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx` — проверка независимых query-состояний и кеша.
|
||
- Create `apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx` — общая сводка и частичное состояние.
|
||
- Create `apps/frontend/src/pages/broker/BrokerAccountCard.tsx` — успешная карточка, skeleton и локальная ошибка с retry.
|
||
- Create `apps/frontend/src/pages/broker/BrokerAllocationBar.tsx` — доступная горизонтальная полоса распределения.
|
||
- Create `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx` — page/component tests.
|
||
- Modify `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx` — orchestration новой страницы.
|
||
- Modify `apps/frontend/src/pages/broker/BrokerPages.test.tsx` — удалить старый поверхностный тест списка счетов.
|
||
- Modify `apps/frontend/src/styles.css` — визуальная система overview, focus/hover, skeleton и responsive rules.
|
||
- Modify `docs/features/broker-accounts-overview/tasks.md` — отмечать выполненные задачи.
|
||
- Modify `docs/roadmap.md` — отметить фичу реализованной только после всех проверок.
|
||
|
||
### Task 1: Чистая модель агрегации
|
||
|
||
**Files:**
|
||
|
||
- Create: `apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts`
|
||
- Create: `apps/frontend/src/pages/broker/brokerAccountsOverview.ts`
|
||
|
||
- [ ] **Step 1: Написать падающие unit-тесты**
|
||
|
||
Покрыть одним набором тестов:
|
||
|
||
```ts
|
||
import { describe, expect, it } from 'vitest';
|
||
import type { BrokerPortfolio } from '../../api/responses';
|
||
import { aggregateBrokerAccounts } from './brokerAccountsOverview';
|
||
|
||
function portfolio(
|
||
id: string,
|
||
currency: string,
|
||
total: number,
|
||
daily: number | null,
|
||
cash: number,
|
||
): BrokerPortfolio {
|
||
return {
|
||
account: {
|
||
id,
|
||
type: 'brokerage',
|
||
name: id,
|
||
status: 'ACCOUNT_STATUS_OPEN',
|
||
openedAt: '2022-06-16T00:00:00.000Z',
|
||
accessLevel: null,
|
||
},
|
||
positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 },
|
||
totals: {
|
||
shares: { currency, units: '0', nano: 0, value: total * 0.5 },
|
||
bonds: { currency, units: '0', nano: 0, value: total * 0.3 },
|
||
etf: null,
|
||
currencies: { currency, units: '0', nano: 0, value: total * 0.2 },
|
||
futures: null,
|
||
options: null,
|
||
structuredProducts: null,
|
||
dfa: null,
|
||
portfolio: { currency, units: '0', nano: 0, value: total },
|
||
},
|
||
yields: {
|
||
expectedPercent: 10,
|
||
daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily },
|
||
dailyPercent: null,
|
||
},
|
||
cash: [{ currency, units: '0', nano: 0, value: cash }],
|
||
blockedCash: [],
|
||
asOf: '2026-06-19T10:00:00.000Z',
|
||
};
|
||
}
|
||
|
||
describe('aggregateBrokerAccounts', () => {
|
||
it('sums comparable portfolios and uses the specified daily percent formula', () => {
|
||
const result = aggregateBrokerAccounts([
|
||
portfolio('a', 'RUB', 1_100, 100, 200),
|
||
portfolio('b', 'RUB', 2_200, 200, 300),
|
||
]);
|
||
|
||
expect(result.portfolios).toEqual([
|
||
expect.objectContaining({
|
||
currency: 'RUB',
|
||
total: 3_300,
|
||
daily: 300,
|
||
dailyPercent: 10,
|
||
allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 },
|
||
}),
|
||
]);
|
||
expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]);
|
||
});
|
||
|
||
it('keeps different currencies separate', () => {
|
||
const result = aggregateBrokerAccounts([
|
||
portfolio('rub', 'RUB', 1_100, 100, 200),
|
||
portfolio('usd', 'USD', 550, 50, 25),
|
||
]);
|
||
|
||
expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([
|
||
{ currency: 'RUB', total: 1_100 },
|
||
{ currency: 'USD', total: 550 },
|
||
]);
|
||
});
|
||
|
||
it('does not expose a daily percent when one account lacks daily data', () => {
|
||
const result = aggregateBrokerAccounts([
|
||
portfolio('a', 'RUB', 1_100, 100, 200),
|
||
portfolio('b', 'RUB', 2_000, null, 300),
|
||
]);
|
||
|
||
expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null });
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Запустить unit-тест и подтвердить RED**
|
||
|
||
Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend`
|
||
|
||
Expected: FAIL с ошибкой импорта `./brokerAccountsOverview`.
|
||
|
||
- [ ] **Step 3: Реализовать минимальную чистую модель**
|
||
|
||
Создать публичные типы `BrokerAccountsAggregate`, `BrokerCurrencyPortfolioSummary`,
|
||
`BrokerCurrencyCashSummary` и функцию:
|
||
|
||
```ts
|
||
export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate;
|
||
```
|
||
|
||
Правила реализации:
|
||
|
||
- пропускать портфель без `totals.portfolio` или без currency;
|
||
- группировать `totals.portfolio`, `yields.daily` и классы активов по currency портфеля;
|
||
- считать `other` как `max(0, portfolio - shares - bonds - etf - currencies)`;
|
||
- группировать `portfolio.cash` независимо по валюте;
|
||
- если хотя бы в одном портфеле валютной группы нет сопоставимого `yields.daily`, возвращать для
|
||
группы `daily: null` и `dailyPercent: null`;
|
||
- иначе считать `dailyPercent = daily / (total - daily) * 100`, только если знаменатель положителен;
|
||
- сортировать валютные группы по первому появлению во входном массиве, не по алфавиту.
|
||
|
||
- [ ] **Step 4: Запустить unit-тест и подтвердить GREEN**
|
||
|
||
Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend`
|
||
|
||
Expected: PASS, 3 tests.
|
||
|
||
- [ ] **Step 5: Добавить edge cases**
|
||
|
||
Добавить тесты для неположительной стоимости начала дня, отрицательного residual `other`, пустого
|
||
массива, отсутствующего `totals.portfolio` и cash в нескольких валютах. Реализация должна возвращать
|
||
конечные числа и никогда не смешивать валюты.
|
||
|
||
- [ ] **Step 6: Запустить unit-тесты и commit**
|
||
|
||
Run: `npx vitest run src/pages/broker/brokerAccountsOverview.test.ts -w apps/frontend`
|
||
|
||
Expected: PASS.
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/broker/brokerAccountsOverview.ts apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts
|
||
git commit -m "feat: aggregate broker account summaries"
|
||
```
|
||
|
||
### Task 2: Независимые portfolio queries
|
||
|
||
**Files:**
|
||
|
||
- Create: `apps/frontend/src/hooks/useBrokerAccountPortfolios.ts`
|
||
- Create: `apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx`
|
||
|
||
- [ ] **Step 1: Написать падающий hook-тест**
|
||
|
||
Mock `getBrokerPortfolio`, отрендерить hook с двумя счетами и проверить, что вызываются `acc-1` и
|
||
`acc-2`, а результат сохраняет соответствие `account → query` независимо от порядка завершения
|
||
Promise. Второй тест должен создать `QueryClient`, заранее положить портфель в key
|
||
`['broker', 'portfolio', 'acc-1']` и подтвердить, что hook использует то же кешированное значение.
|
||
|
||
- [ ] **Step 2: Запустить hook-тест и подтвердить RED**
|
||
|
||
Run: `npx vitest run src/hooks/useBrokerAccountPortfolios.test.tsx -w apps/frontend`
|
||
|
||
Expected: FAIL с ошибкой импорта `useBrokerAccountPortfolios`.
|
||
|
||
- [ ] **Step 3: Реализовать hook через `useQueries`**
|
||
|
||
```ts
|
||
import { useQueries } from '@tanstack/react-query';
|
||
import { getBrokerPortfolio } from '../api/broker';
|
||
import type { BrokerAccount, BrokerPortfolio } from '../api/responses';
|
||
|
||
export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) {
|
||
const queries = useQueries({
|
||
queries: accounts.map((account) => ({
|
||
queryKey: ['broker', 'portfolio', account.id],
|
||
queryFn: async (): Promise<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
|
||
staleTime: 60_000,
|
||
retry: 2,
|
||
refetchOnWindowFocus: false,
|
||
})),
|
||
});
|
||
|
||
return accounts.map((account, index) => ({ account, query: queries[index] }));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Запустить hook-тест и подтвердить GREEN**
|
||
|
||
Run: `npx vitest run src/hooks/useBrokerAccountPortfolios.test.tsx -w apps/frontend`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/hooks/useBrokerAccountPortfolios.ts apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx
|
||
git commit -m "feat: load broker account portfolios in parallel"
|
||
```
|
||
|
||
### Task 3: Компоненты и состояния страницы
|
||
|
||
**Files:**
|
||
|
||
- Create: `apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx`
|
||
- Create: `apps/frontend/src/pages/broker/BrokerAllocationBar.tsx`
|
||
- Create: `apps/frontend/src/pages/broker/BrokerAccountCard.tsx`
|
||
- Create: `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx`
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.tsx`
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx`
|
||
|
||
- [ ] **Step 1: Написать page/component tests до реализации**
|
||
|
||
Mock `useBrokerAccounts` и `useBrokerAccountPortfolios`. Проверить отдельными тестами:
|
||
|
||
- heading, агрегированную сумму, дневной результат и две карточки;
|
||
- подписи `Брокерский счёт` и `ИИС`, форматированную дату открытия и отсутствие ID/raw enum;
|
||
- href всей карточки `/broker/:encodedAccountId`;
|
||
- skeleton при загрузке списка;
|
||
- пустое состояние при `accounts: []`;
|
||
- частичную сводку `Доступно по 1 из 2 счетов`;
|
||
- локальный alert и кнопку `Повторить` для ошибочного query;
|
||
- вызов `query.refetch()` по кнопке retry;
|
||
- раздельное отображение RUB и USD без суммирования.
|
||
|
||
Для денежных assertions использовать regexp с обычным и non-breaking space, например:
|
||
|
||
```ts
|
||
expect(screen.getByText(/3[\s\u00a0]?300[\s\u00a0]?₽/)).toBeInTheDocument();
|
||
```
|
||
|
||
- [ ] **Step 2: Запустить page-тест и подтвердить RED**
|
||
|
||
Run: `npx vitest run src/pages/broker/BrokerAccountsPage.test.tsx -w apps/frontend`
|
||
|
||
Expected: FAIL, потому что новые компоненты и состояния отсутствуют.
|
||
|
||
- [ ] **Step 3: Реализовать `BrokerAllocationBar`**
|
||
|
||
Компонент получает `BrokerAllocationItem[]`, строит сегменты с inline `width: percent%`, добавляет
|
||
`role="img"`, осмысленный `aria-label` со всеми долями и текстовую легенду. Нулевые сегменты не
|
||
рендерятся; цвет не является единственным способом различить классы.
|
||
|
||
- [ ] **Step 4: Реализовать `BrokerAccountsSummary`**
|
||
|
||
Компонент получает aggregate, `loadedCount` и `totalCount`. Он показывает:
|
||
|
||
- статус `Совокупный капитал · N счетов` либо `Доступно по N из M счетов`;
|
||
- по одному блоку стоимости/дневного результата на валюту;
|
||
- свободные деньги отдельным списком валют;
|
||
- allocation bar только когда доступна одна portfolio currency;
|
||
- placeholder `—`, если ни один портфель ещё не загружен.
|
||
|
||
- [ ] **Step 5: Реализовать `BrokerAccountCard`**
|
||
|
||
Компонент получает `account` и query result. Три ветки должны иметь стабильную геометрию:
|
||
|
||
```tsx
|
||
if (query.isPending) return <BrokerAccountCardSkeleton account={account} />;
|
||
if (query.error || !query.data) {
|
||
return <BrokerAccountCardError account={account} onRetry={() => query.refetch()} />;
|
||
}
|
||
return <BrokerAccountCardContent account={account} portfolio={query.data} />;
|
||
```
|
||
|
||
Успешная карточка — одна `Link` на `/broker/${encodeURIComponent(account.id)}`. Внутри показать
|
||
название, тип, дату открытия, стоимость, `yields.daily`, `dailyPercent`, `expectedPercent` и allocation
|
||
bar из существующего `buildBrokerAllocation(portfolio).sectors`.
|
||
|
||
- [ ] **Step 6: Переписать orchestration `BrokerAccountsPage`**
|
||
|
||
Страница должна:
|
||
|
||
1. вызвать `useBrokerAccounts()`;
|
||
2. передать `accounts ?? []` в `useBrokerAccountPortfolios` без условного вызова hooks;
|
||
3. построить aggregate только из `query.data` успешных записей;
|
||
4. показать page error только при ошибке списка;
|
||
5. показать отдельное empty state для пустого списка;
|
||
6. отрендерить summary и вертикальный список карточек.
|
||
|
||
- [ ] **Step 7: Удалить устаревший тест из `BrokerPages.test.tsx`**
|
||
|
||
Удалить test case `renders broker and IIS accounts` и неиспользуемые imports `accountHook` и
|
||
`BrokerAccountsPage`; покрытие новой страницы живёт в `BrokerAccountsPage.test.tsx`.
|
||
|
||
- [ ] **Step 8: Запустить component tests и подтвердить GREEN**
|
||
|
||
Run: `npx vitest run src/pages/broker/BrokerAccountsPage.test.tsx src/pages/broker/BrokerPages.test.tsx -w apps/frontend`
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/broker/BrokerAccountsPage.tsx apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx apps/frontend/src/pages/broker/BrokerAccountCard.tsx apps/frontend/src/pages/broker/BrokerAllocationBar.tsx apps/frontend/src/pages/broker/BrokerPages.test.tsx
|
||
git commit -m "feat: add informative broker accounts overview"
|
||
```
|
||
|
||
### Task 4: Визуальная система и responsive QA
|
||
|
||
**Files:**
|
||
|
||
- Modify: `apps/frontend/src/styles.css`
|
||
- Modify: `apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx`
|
||
|
||
- [ ] **Step 1: Добавить семантические CSS-классы**
|
||
|
||
Добавить блоки `.broker-accounts`, `__header`, `__summary`, `__summary-metrics`, `__list`,
|
||
`.broker-account-card`, `__topline`, `__value`, `__metrics`, `.broker-allocation-bar`, `__track`,
|
||
`__legend`, `__error` и `__empty`.
|
||
|
||
Визуальное направление:
|
||
|
||
- тёплый нейтральный фон страницы и глубокий зелёный summary без градиента;
|
||
- serif-акцент только для крупных денежных значений, основной текст наследует текущую гарнитуру;
|
||
- тонкие границы и мягкая тень карточек, без вложенных «карточек в карточке»;
|
||
- один заметный hover карточки: небольшой подъём и усиление тени;
|
||
- `:focus-visible` с контрастным outline;
|
||
- positive/negative цвета всегда сопровождаются знаком и текстом;
|
||
- `prefers-reduced-motion: reduce` отключает transform/transition.
|
||
|
||
- [ ] **Step 2: Добавить responsive rules**
|
||
|
||
При `max-width: 720px` summary metrics и card metrics переходят в одну колонку, легенда allocation
|
||
переносится, денежные значения уменьшаются через `clamp()`, а карточка и кнопка retry сохраняют
|
||
минимальную интерактивную высоту 44px. Горизонтальный overflow на `.broker-accounts` запрещён.
|
||
|
||
- [ ] **Step 3: Запустить frontend проверки**
|
||
|
||
Run: `npm run test -w apps/frontend`
|
||
|
||
Expected: PASS.
|
||
|
||
Run: `npm run lint -w apps/frontend`
|
||
|
||
Expected: PASS без warnings.
|
||
|
||
Run: `npm run build -w apps/frontend`
|
||
|
||
Expected: успешный TypeScript и Vite build.
|
||
|
||
- [ ] **Step 4: Проверить страницу в локальном браузере**
|
||
|
||
Запустить frontend и backend по README. Проверить `/broker` при ширинах 1280px и 390px:
|
||
|
||
- нет горизонтального overflow;
|
||
- summary визуально доминирует, но карточки остаются читаемыми;
|
||
- карточки и retry доступны с клавиатуры;
|
||
- loading не меняет геометрию страницы;
|
||
- partial error не скрывает успешные счета;
|
||
- названия, крупные суммы и allocation legend не перекрываются.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/styles.css apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx
|
||
git commit -m "style: polish broker accounts overview"
|
||
```
|
||
|
||
### Task 5: Финальная документация и Definition of Done
|
||
|
||
**Files:**
|
||
|
||
- Modify: `docs/features/broker-accounts-overview/tasks.md`
|
||
- Modify: `docs/roadmap.md`
|
||
|
||
- [ ] **Step 1: Отметить выполненные tasks**
|
||
|
||
Поставить `[x]` только после соответствующих commit и проверок. Не менять ADR-012: решение уже
|
||
Accepted и реализация ему соответствует.
|
||
|
||
- [ ] **Step 2: Обновить roadmap**
|
||
|
||
Изменить строку фичи на:
|
||
|
||
```md
|
||
- [x] [Информативный обзор брокерских счетов](features/broker-accounts-overview/spec.md) — реализовано.
|
||
```
|
||
|
||
- [ ] **Step 3: Выполнить полный verification gate**
|
||
|
||
Run: `npm run test -w apps/frontend && npm run lint -w apps/frontend && npm run build -w apps/frontend`
|
||
|
||
Expected: все команды завершаются с exit code 0.
|
||
|
||
Run: `npm run build -w apps/docs`
|
||
|
||
Expected: Docusaurus build succeeds.
|
||
|
||
- [ ] **Step 4: Провести code review**
|
||
|
||
Использовать `superpowers:requesting-code-review`. Исправить замечания только после технической
|
||
проверки; при изменении поведения сначала синхронизировать spec/plan.
|
||
|
||
- [ ] **Step 5: Финальный commit документации**
|
||
|
||
```bash
|
||
git add docs/features/broker-accounts-overview/tasks.md docs/roadmap.md
|
||
git commit -m "docs: complete broker accounts overview"
|
||
```
|
||
|
||
## Definition of Done
|
||
|
||
- Все Acceptance Criteria из `spec.md` имеют component или unit coverage.
|
||
- Frontend tests, lint и build проходят.
|
||
- Docusaurus build проходит.
|
||
- Desktop и mobile UI проверены в браузере.
|
||
- `tasks.md` и roadmap соответствуют реализации.
|
||
- Нет изменений backend, OpenAPI или `apps/frontend/src/api/types.ts`.
|
||
- Нет незавершённых маркеров или отложенных требований.
|