diff --git a/apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md b/apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md new file mode 100644 index 0000000..f0d00a7 --- /dev/null +++ b/apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md @@ -0,0 +1,72 @@ +# ADR-014: Frontend FSD market pages + +**Статус:** Accepted +**Дата:** 2026-06-20 +**Участники решения:** Product Engineer, Tech Lead + +## Context + +После `ADR-013` broker-домен уже получил первый рабочий FSD-срез, но market pages (`HomePage`, +`StockPage`, `BondPage`) и связанные UI-блоки всё ещё оставались в исторической технической +структуре `components/`, `hooks/` и плоского `pages/`. Из-за этого frontend оказался в +промежуточном состоянии: часть экранов уже живёт через FSD public API, а публичная документация +всё ещё описывает market-раздел как legacy-only область. + +Для следующей итерации нужна управляемая миграция, которая: + +- продолжает FSD-переход после broker pilot, не превращая его в big-bang переписывание frontend; +- переносит market pages и связанные UI/query entrypoints в `pages/`, `widgets/` и `entities/`; +- сохраняет совместимость со старыми импортами на время coexistence через тонкие re-export shim; +- оставляет orchestration route params, query composition и loading/not-found состояния в + page-layer, не размывая ответственность widgets. + +## Options + +### Option A. Полная market FSD migration с coexistence через shims + +Перенести `HomePage`, `StockPage`, `BondPage`, `SearchBar`, `PriceChart`, `StockDetails`, +`BondDetails`, `DividendsTable` и `useSearch` в FSD-срезы. Сохранить старые entrypoints +(`components/*`, `hooks/useSearch.ts`, `pages/*.tsx`) как тонкие реэкспорт-шмы на переходный +период. Data orchestration и route-specific логика остаются в `pages/home`, `pages/stock`, +`pages/bond`. + +### Option B. Частичный перенос только UI-компонентов + +Перенести только `SearchBar`, `PriceChart`, `StockDetails`, `BondDetails` и `DividendsTable` в +`widgets/`, но оставить page entrypoints и `useSearch` в исторических каталогах. + +### Option C. Ускоренная миграция без shim-слоя + +Сразу заменить все старые import paths на новые FSD entrypoints и удалить legacy-файлы в рамках +одного изменения. + +## Decision + +Принят Option A: выполнить market-pages миграцию как следующую итерацию после broker pilot и вести +её через coexistence FSD-слоёв и legacy entrypoints. + +Решение включает следующие правила: + +- `pages/home`, `pages/stock`, `pages/bond` становятся primary route entrypoints; +- `widgets/search-bar`, `widgets/price-chart`, `widgets/stock-details`, `widgets/bond-details`, + `widgets/dividends-table` становятся primary UI entrypoints для market-сценариев; +- `entities/search/model/useSearch.ts` становится primary местом для search query hook; +- legacy `components/*`, `hooks/useSearch.ts` и плоские `pages/*.tsx` сохраняются как тонкие + re-export shim-файлы на переходный период; +- orchestration остаётся в page-layer: `useParams`, вычисление диапазонов дат, вызовы domain query + hooks, а также loading/error/not-found состояния не переносятся в widgets; +- взаимодействие между слоями идёт через public API (`index.ts`), без внешних deep imports в `ui/` + и `model/`. + +## Consequences + +- Market-раздел получает ту же архитектурную форму, что и broker pilot, и становится вторым + эталонным FSD-срезом frontend. +- Migration risk снижается: coexistence через shims позволяет переключать импортирующие места без + массового удаления legacy entrypoints. +- В проекте временно сохраняется двойная поверхность импортов, поэтому архитектурная дисциплина + по-прежнему поддерживается структурой каталогов и code review. +- Orchestration остаётся ближе к маршрутам, поэтому widgets остаются prop-driven и переиспользуемыми + без знания о `react-router` или query lifecycle. +- Published frontend docs и ADR index должны обновляться синхронно с такой миграцией, иначе + документация начнёт отставать от фактической структуры кода. diff --git a/apps/docs/docs/adr/index.md b/apps/docs/docs/adr/index.md index a9d7669..66378c7 100644 --- a/apps/docs/docs/adr/index.md +++ b/apps/docs/docs/adr/index.md @@ -15,5 +15,6 @@ | [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC | | [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend | | [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена | +| [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets | Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе. diff --git a/apps/docs/docs/frontend/components.md b/apps/docs/docs/frontend/components.md index 4b5b3e5..c043ae0 100644 --- a/apps/docs/docs/frontend/components.md +++ b/apps/docs/docs/frontend/components.md @@ -1,13 +1,16 @@ # Компоненты -## Layout (`components/Layout.tsx`) +Published docs ниже перечисляют primary entrypoints. Legacy файлы в `components/` сохранены как +тонкие re-export shim'ы для coexistence со старыми импортами. + +## Layout (`app/layouts/AppLayout.tsx`, legacy shim: `components/Layout.tsx`) Базовый layout с шапкой и ``. - Шапка: логотип "MoexVibe" (ссылка на `/`) + `SearchBar` - Основной контент: max-width 1200px, padding 24px -## SearchBar (`components/SearchBar.tsx`) +## SearchBar (`widgets/search-bar/ui/SearchBar.tsx`, public API: `widgets/search-bar`) Поиск инструментов с debounce (300ms). @@ -16,7 +19,9 @@ - При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid` - Закрывается при клике вне компонента -## PriceChart (`components/PriceChart.tsx`) +Legacy path `components/SearchBar.tsx` остаётся shim-файлом и не является primary entrypoint. + +## PriceChart (`widgets/price-chart/ui/PriceChart.tsx`, public API: `widgets/price-chart`) График цены на основе `lightweight-charts` v4. @@ -25,16 +30,30 @@ - Цвета: зелёный для роста, красный для падения - Адаптивная ширина (resize listener) -## StockDetails (`components/StockDetails.tsx`) +Legacy path `components/PriceChart.tsx` остаётся shim-файлом. + +## StockDetails (`widgets/stock-details/ui/StockDetails.tsx`, public API: `widgets/stock-details`) Карточка с информацией об акции. - Принимает `ShareResponse` - Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга -## BondDetails (`components/BondDetails.tsx`) +Legacy path `components/StockDetails.tsx` остаётся shim-файлом. + +## BondDetails (`widgets/bond-details/ui/BondDetails.tsx`, public API: `widgets/bond-details`) Карточка с информацией об облигации. - Принимает `BondResponse` - Отображает: название, ISIN, цена (в % от номинала), номинал, дата погашения, купон (сумма/%), период купона, НКД, доходность к погашению, дюрация, тип + +Legacy path `components/BondDetails.tsx` остаётся shim-файлом. + +## DividendsTable (`widgets/dividends-table/ui/DividendsTable.tsx`, public API: `widgets/dividends-table`) + +Таблица дивидендов для страницы акции. + +- Принимает готовый список дивидендов через props +- Рендерит payout-историю без собственных query-вызовов +- Используется page-layer как prop-driven widget diff --git a/apps/docs/docs/frontend/hooks.md b/apps/docs/docs/frontend/hooks.md index 79376cc..7aa8348 100644 --- a/apps/docs/docs/frontend/hooks.md +++ b/apps/docs/docs/frontend/hooks.md @@ -4,13 +4,21 @@ | Хук | Query key | Stale time | Описание | |---|---|---|---| -| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (enabled: query ≥ 2 символов) | +| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (primary: `entities/search/model/useSearch.ts`) | | `useStock(secid)` | `['stock', secid]` | 900s | Спецификация акции | | `useStockCandles(secid, interval, from, till)` | `['stockCandles', secid, interval, from, till]` | 3600s | Свечи акции | | `useStockDividends(secid)` | `['stockDividends', secid]` | 86400s | Дивиденды акции | | `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации | | `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации | +## Primary и legacy paths + +- `useSearch` primary path: `apps/frontend/src/entities/search/model/useSearch.ts` +- Public API для search slice: `apps/frontend/src/entities/search/index.ts` +- Legacy compatibility path: `apps/frontend/src/hooks/useSearch.ts` — это shim, который + реэкспортирует primary hook +- Market pages используют `entities/search` как source of truth, а не historical hook path + ## Конфигурация Query ```typescript @@ -27,11 +35,19 @@ const queryClient = new QueryClient({ ## Паттерн hook -Каждый хук: +Для historical hooks и FSD hooks общий принцип один и тот же: -1. Вызывает функцию из `api/client.ts` +1. Хук вызывает ближайший domain/shared API helper 2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`) -3. Типизирован через рукописные типы из `api/responses.ts` +3. Типизируется через актуальные response types из `shared/api/responses.ts` + +В legacy-слое helper может приходить из historical `api/*`, а в FSD-срезах — из domain API +файлов вроде `entities/stock/api/stockApi.ts` или `entities/bond/api/bondApi.ts`. + +Для FSD-срезов domain-specific hooks постепенно переезжают ближе к своим сущностям. Для market +pages это уже сделано для `entities/search`, `entities/stock` и `entities/bond`; для portfolio +домен уже использует `entities/portfolio`; legacy imports сохраняются только как transitional +shim-слой там, где миграция ещё не завершена. ```typescript export function useStock(secid: string) { diff --git a/apps/docs/docs/frontend/overview.md b/apps/docs/docs/frontend/overview.md index ac8b2cd..19c161f 100644 --- a/apps/docs/docs/frontend/overview.md +++ b/apps/docs/docs/frontend/overview.md @@ -14,6 +14,10 @@ React SPA, собранная с Vite. ## Структура исходников +Published-структура ниже описывает primary FSD entrypoints. Historical каталоги `api/`, `context/`, +`hooks/`, `components/` ещё присутствуют в кодовой базе, но для уже мигрированных областей они всё +чаще играют роль transitional shim-слоя, а не source of truth. + ``` apps/frontend/src/ ├── main.tsx # Точка входа @@ -31,51 +35,35 @@ apps/frontend/src/ │ └── layouts/ │ ├── AppLayout.tsx # Шапка + │ └── index.ts -├── api/ -│ ├── auth.ts # re-export shim → entities/session/api -│ ├── client.ts # HTTP-клиент (fetch) -│ ├── portfolio.ts # Portfolio API helpers -│ ├── responses.ts # Типы ответов (ручные) -│ ├── screener.ts # Screener API helpers -│ └── types.ts # Типы из openapi-typescript -├── context/ -│ └── AuthContext.tsx # re-export shim → app/providers/SessionProvider -├── hooks/ -│ ├── useAuth.ts # re-export shim → entities/session/model -│ ├── usePortfolio.ts -│ ├── usePortfolioAnalytics.ts -│ ├── usePortfolioMutations.ts -│ ├── usePortfolios.ts -│ ├── usePositionMutations.ts -│ ├── useScreener.ts -│ ├── useSearch.ts -│ ├── useStock.ts -│ ├── useStockCandles.ts -│ ├── useStockDividends.ts -│ ├── useBond.ts -│ └── useBondCandles.ts -├── components/ -│ ├── Layout.tsx # re-export shim → app/layouts -│ ├── ProtectedRoute.tsx # re-export shim → app/routing -│ ├── SearchBar.tsx -│ ├── PriceChart.tsx -│ ├── StockDetails.tsx -│ ├── BondDetails.tsx -│ └── portfolios/ +├── shared/ +│ ├── api/ # shared API client, response types, generated OpenAPI types +│ └── ui/ # shared UI primitives without domain logic ├── entities/ │ ├── session/ # FSD: auth/session (api, model/context + hook) +│ ├── search/ # FSD: market search query slice +│ ├── stock/ # FSD: stock api + query hooks +│ ├── bond/ # FSD: bond api + query hooks +│ ├── portfolio/ # FSD: portfolio api + query hooks │ ├── broker-account/ # FSD: account slice (api, model, ui) │ ├── broker-position/ # FSD: position slice (api, model) │ └── broker-operation/ # FSD: operation slice (api, model) ├── widgets/ +│ ├── search-bar/ # FSD: instrument search widget +│ ├── price-chart/ # FSD: market chart widget +│ ├── stock-details/ # FSD: stock details widget +│ ├── bond-details/ # FSD: bond details widget +│ ├── dividends-table/ # FSD: stock dividends widget │ ├── broker-account-card/ # FSD: account card widget │ ├── broker-accounts-summary/ # FSD: accounts summary widget │ ├── broker-allocation-chart/ # FSD: allocation chart widget │ └── broker-operations-table/ # FSD: operations table widget ├── pages/ -│ ├── HomePage.tsx -│ ├── StockPage.tsx -│ ├── BondPage.tsx +│ ├── HomePage.tsx # re-export shim → pages/home +│ ├── StockPage.tsx # re-export shim → pages/stock +│ ├── BondPage.tsx # re-export shim → pages/bond +│ ├── home/ # FSD: home page entrypoint +│ ├── stock/ # FSD: stock page entrypoint +│ ├── bond/ # FSD: bond page entrypoint │ ├── LoginPage.tsx │ ├── RegisterPage.tsx │ ├── ProfilePage.tsx @@ -88,6 +76,10 @@ apps/frontend/src/ ├── test/ │ ├── handlers.ts # MSW handlers │ └── test-utils.tsx +├── api/ # historical paths + compatibility shims around shared/entities +├── context/ # historical compatibility layer +├── hooks/ # historical compatibility layer +└── components/ # historical compatibility layer └── styles.css ``` @@ -117,6 +109,37 @@ Broker-домен переведён в пилотный FSD срез: - `widgets/broker-*` — screen-level композиция - `entities/broker-*` — доменные срезы (api, model, ui) +### market pages (следующая итерация) + +После broker pilot market-раздел также переведён на FSD entrypoints: + +- `pages/home`, `pages/stock`, `pages/bond` — primary route entrypoints; +- `widgets/search-bar`, `widgets/price-chart`, `widgets/stock-details`, + `widgets/bond-details`, `widgets/dividends-table` — primary UI entrypoints; +- `entities/search` — search query slice; +- `entities/stock` и `entities/bond` — query hooks и domain API для market pages. + +При этом orchestration остаётся в page-layer: route params, вычисление диапазонов дат, загрузка +данных и loading/not-found состояния не переносятся в widgets. + ### Остальные домены -`portfolio`, `screener` пока остаются в исторической технической структуре. +`screener` в основном остаётся в historical технической структуре. `portfolio` находится в +промежуточном состоянии: page-level структура ещё не полностью унифицирована, но domain API/model +слой уже вынесен в `entities/portfolio`. + +### Legacy coexistence + +После app/session, broker и market migration в проекте ещё остаются top-level `api/`, `context/`, +`hooks/`, `components/` и плоские `pages/*.tsx`. Для уже мигрированных зон они служат главным +образом как compatibility shim-слой: + +- `components/SearchBar.tsx` → `widgets/search-bar` +- `components/PriceChart.tsx` → `widgets/price-chart` +- `components/StockDetails.tsx` → `widgets/stock-details` +- `components/BondDetails.tsx` → `widgets/bond-details` +- `hooks/useSearch.ts` → `entities/search` +- `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` → новые page entrypoints + +Published docs описывают primary FSD entrypoints; legacy paths остаются временной поверхностью +совместимости. diff --git a/apps/docs/docs/frontend/routes.md b/apps/docs/docs/frontend/routes.md index 350f8cc..b23113f 100644 --- a/apps/docs/docs/frontend/routes.md +++ b/apps/docs/docs/frontend/routes.md @@ -1,20 +1,31 @@ # Маршруты -Определены в `apps/frontend/src/routes.tsx`. +Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRoutes.tsx`. | Path | Component | Доступ | Описание | |---|---|---|---| -| `/` | `HomePage` | Public | Главная страница | -| `/stocks/:secid` | `StockPage` | Public | Страница акции | -| `/bonds/:secid` | `BondPage` | Public | Страница облигации | +| `/` | `HomePage` from `pages/home` | Public | Главная страница | +| `/stocks/:secid` | `StockPage` from `pages/stock` | Public | Страница акции | +| `/bonds/:secid` | `BondPage` from `pages/bond` | Public | Страница облигации | | `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг | | `/login` | `LoginPage` | Public | Вход | | `/register` | `RegisterPage` | Public | Регистрация | | `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя | | `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей | | `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля | +| `/broker` | `BrokerAccountsPage` from `pages/broker-accounts` | Protected | Список брокерских счетов | +| `/broker/:accountId` | `BrokerAccountLayout` + nested pages | Protected | Детальная область брокерского счёта | -Все страницы обёрнуты в `Layout`, который содержит: +Market route entrypoints теперь живут в: + +- `apps/frontend/src/pages/home` +- `apps/frontend/src/pages/stock` +- `apps/frontend/src/pages/bond` + +Legacy `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` сохранены как shim-файлы, +но не являются source of truth для новых импортов. + +Все страницы обёрнуты в `AppLayout`, который содержит: - Шапку с логотипом (ссылка на `/`) и `SearchBar` - `
` с максимальной шириной 1200px @@ -24,7 +35,7 @@ ```tsx - }> + }> } /> } /> } /> @@ -55,6 +66,27 @@ } /> + + + + } + /> + + + + } + > + } /> + } /> + } /> + } /> + ``` diff --git a/apps/frontend/src/app/layouts/AppLayout.tsx b/apps/frontend/src/app/layouts/AppLayout.tsx index 0a4918a..46f4408 100644 --- a/apps/frontend/src/app/layouts/AppLayout.tsx +++ b/apps/frontend/src/app/layouts/AppLayout.tsx @@ -1,5 +1,5 @@ import { Outlet, Link, useNavigate } from 'react-router-dom'; -import { SearchBar } from '@/components/SearchBar'; +import { SearchBar } from '@/widgets/search-bar'; import { useSession } from '@/entities/session/model/useSession'; export function AppLayout() { diff --git a/apps/frontend/src/app/routing/AppRoutes.tsx b/apps/frontend/src/app/routing/AppRoutes.tsx index 96fdc16..b11cf6a 100644 --- a/apps/frontend/src/app/routing/AppRoutes.tsx +++ b/apps/frontend/src/app/routing/AppRoutes.tsx @@ -1,9 +1,9 @@ import { Routes, Route } from 'react-router-dom'; import { AppLayout } from '../layouts/AppLayout'; import { ProtectedRoute } from './ProtectedRoute'; -import { HomePage } from '@/pages/HomePage'; -import { StockPage } from '@/pages/StockPage'; -import { BondPage } from '@/pages/BondPage'; +import { HomePage } from '@/pages/home'; +import { StockPage } from '@/pages/stock'; +import { BondPage } from '@/pages/bond'; import { LoginPage } from '@/pages/LoginPage'; import { RegisterPage } from '@/pages/RegisterPage'; import { ProfilePage } from '@/pages/ProfilePage'; diff --git a/apps/frontend/src/components/BondDetails.tsx b/apps/frontend/src/components/BondDetails.tsx index dbf7588..b1b0d1b 100644 --- a/apps/frontend/src/components/BondDetails.tsx +++ b/apps/frontend/src/components/BondDetails.tsx @@ -1,83 +1 @@ -import type { BondResponse } from '@/shared/api/responses'; - -interface BondDetailsProps { - bond: BondResponse; -} - -const rowStyle: React.CSSProperties = { - display: 'flex', - justifyContent: 'space-between', - padding: '8px 0', - borderBottom: '1px solid #eee', -}; - -export function BondDetails({ bond }: BondDetailsProps) { - const md = bond.marketData; - - return ( -
-
-

{bond.shortName}

-
{bond.isin}
-
- -
- {md.price?.toFixed(2) ?? '—'}% -
- -
-
- Номинал - - {bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit} - -
-
- Дата погашения - {bond.matDate} -
-
- Купон - - {md.couponValue ?? '—'} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''} - -
-
- Период купона - {bond.couponPeriod} дней -
-
- Следующий купон - {md.nextCouponDate ?? '—'} -
-
- НКД - {md.accruedInt?.toFixed(2) ?? '—'} ₽ -
-
- Доходность к погашению - {md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'} -
-
- Дюрация - {md.duration != null ? md.duration.toFixed(2) : '—'} -
-
- Тип - {bond.bondType} -
-
- ISIN - {bond.isin} -
-
-
- ); -} +export { BondDetails } from '@/widgets/bond-details'; diff --git a/apps/frontend/src/components/PriceChart.tsx b/apps/frontend/src/components/PriceChart.tsx index 4df9440..866f322 100644 --- a/apps/frontend/src/components/PriceChart.tsx +++ b/apps/frontend/src/components/PriceChart.tsx @@ -1,71 +1 @@ -import { useEffect, useRef } from 'react'; -import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts'; - -interface PriceChartProps { - data: Array<{ - open: number; - high: number; - low: number; - close: number; - begin: string; - }>; - height?: number; -} - -export function PriceChart({ data, height = 400 }: PriceChartProps) { - const chartContainerRef = useRef(null); - - useEffect(() => { - if (!chartContainerRef.current) return; - - const chart = createChart(chartContainerRef.current, { - layout: { - background: { type: ColorType.Solid, color: '#ffffff' }, - textColor: '#333', - }, - width: chartContainerRef.current.clientWidth, - height, - grid: { - vertLines: { color: '#f0f0f0' }, - horzLines: { color: '#f0f0f0' }, - }, - timeScale: { - timeVisible: false, - }, - }); - - const candleSeries = chart.addCandlestickSeries({ - upColor: '#2e7d32', - downColor: '#c62828', - borderDownColor: '#c62828', - borderUpColor: '#2e7d32', - wickDownColor: '#c62828', - wickUpColor: '#2e7d32', - }); - - const chartData: CandlestickData[] = data.map((candle) => ({ - time: (new Date(candle.begin).getTime() / 1000) as Time, - open: candle.open, - high: candle.high, - low: candle.low, - close: candle.close, - })); - - candleSeries.setData(chartData); - chart.timeScale().fitContent(); - - const handleResize = () => { - if (chartContainerRef.current) { - chart.applyOptions({ width: chartContainerRef.current.clientWidth }); - } - }; - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - chart.remove(); - }; - }, [data, height]); - - return
; -} +export { PriceChart } from '@/widgets/price-chart'; diff --git a/apps/frontend/src/components/SearchBar.tsx b/apps/frontend/src/components/SearchBar.tsx index 5c30b7e..2809c46 100644 --- a/apps/frontend/src/components/SearchBar.tsx +++ b/apps/frontend/src/components/SearchBar.tsx @@ -1,108 +1 @@ -import { useState, useRef, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useSearch } from '../hooks/useSearch'; - -export function SearchBar() { - const [query, setQuery] = useState(''); - const [debounced, setDebounced] = useState(''); - const [open, setOpen] = useState(false); - const navigate = useNavigate(); - const ref = useRef(null); - - useEffect(() => { - const id = setTimeout(() => setDebounced(query), 300); - return () => clearTimeout(id); - }, [query]); - - const { data: results, isLoading } = useSearch(debounced); - - useEffect(() => { - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, []); - - const showResults = open && debounced.length >= 2; - - return ( -
- { - setQuery(e.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - style={{ - width: '100%', - padding: '8px 12px', - border: '1px solid #ccc', - borderRadius: 6, - fontSize: 14, - }} - /> - {showResults && ( -
    - {isLoading &&
  • Загрузка...
  • } - {!isLoading && results && results.length === 0 && ( -
  • Ничего не найдено
  • - )} - {!isLoading && - results?.map((item) => ( -
  • { - setOpen(false); - setQuery(''); - navigate( - item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`, - ); - }} - style={{ - padding: '10px 12px', - cursor: 'pointer', - borderBottom: '1px solid #f0f0f0', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - }} - onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')} - onMouseLeave={(e) => (e.currentTarget.style.background = '')} - > - - {item.shortName} - {item.secid} - - - {item.type === 'share' ? 'Акция' : 'Облигация'} - -
  • - ))} -
- )} -
- ); -} +export { SearchBar } from '@/widgets/search-bar'; diff --git a/apps/frontend/src/components/StockDetails.tsx b/apps/frontend/src/components/StockDetails.tsx index a078ac3..ccb5cd2 100644 --- a/apps/frontend/src/components/StockDetails.tsx +++ b/apps/frontend/src/components/StockDetails.tsx @@ -1,83 +1 @@ -import type { ShareResponse } from '@/shared/api/responses'; - -interface StockDetailsProps { - stock: ShareResponse; -} - -const rowStyle: React.CSSProperties = { - display: 'flex', - justifyContent: 'space-between', - padding: '8px 0', - borderBottom: '1px solid #eee', -}; - -export function StockDetails({ stock }: StockDetailsProps) { - const md = stock.marketData; - const isPositive = (md.change ?? 0) >= 0; - - return ( -
-
-

- {stock.shortName} ({stock.secid}) -

-
- {stock.name} · {stock.isin} -
-
- -
- {md.price?.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) ?? '—'}{' '} - - {isPositive ? '+' : ''} - {(md.change ?? 0).toFixed(2)} ({(md.changePercent ?? 0).toFixed(2)}%) - -
- -
-
- Открытие - {md.open?.toFixed(2) ?? '—'} -
-
- Максимум - {md.high?.toFixed(2) ?? '—'} -
-
- Минимум - {md.low?.toFixed(2) ?? '—'} -
-
- Объём - {md.volume.toLocaleString('ru-RU')} -
-
- Капитализация - - {md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'} - -
-
- ISIN - {stock.isin} -
-
- Уровень листинга - {stock.listLevel} -
-
-
- ); -} +export { StockDetails } from '@/widgets/stock-details'; diff --git a/apps/frontend/src/entities/search/index.ts b/apps/frontend/src/entities/search/index.ts new file mode 100644 index 0000000..06cdb77 --- /dev/null +++ b/apps/frontend/src/entities/search/index.ts @@ -0,0 +1 @@ +export { useSearch } from './model/useSearch'; diff --git a/apps/frontend/src/entities/search/model/useSearch.test.tsx b/apps/frontend/src/entities/search/model/useSearch.test.tsx new file mode 100644 index 0000000..70f742b --- /dev/null +++ b/apps/frontend/src/entities/search/model/useSearch.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { type ReactNode } from 'react'; +import { server } from '@/test/server'; +import { useSearch } from '@/entities/search'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useSearch', () => { + it('does not fetch when query is empty', () => { + const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it('does not fetch when query is too short', () => { + const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }); + + expect(result.current.data).toBeUndefined(); + }); + + it('returns search results for valid query', async () => { + const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toBeDefined(); + expect(result.current.data?.length).toBeGreaterThan(0); + expect(result.current.data?.[0].secid).toBe('SBER'); + }); + + it('returns empty array when no results', async () => { + server.use( + http.get(`${API}/securities/search`, () => + HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }), + ), + ); + + const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); + + it('returns error state on network failure', async () => { + server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 }))); + + const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); +}); diff --git a/apps/frontend/src/entities/search/model/useSearch.ts b/apps/frontend/src/entities/search/model/useSearch.ts new file mode 100644 index 0000000..fb5de32 --- /dev/null +++ b/apps/frontend/src/entities/search/model/useSearch.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { searchSecurities } from '@/shared/api/client'; +import type { SearchResultItem } from '@/shared/api/responses'; + +export function useSearch(query: string) { + return useQuery({ + queryKey: ['securities', 'search', query], + queryFn: async () => { + const res = await searchSecurities(query); + + return res.data; + }, + enabled: query.length >= 2, + staleTime: 60_000, + }); +} diff --git a/apps/frontend/src/hooks/useSearch.ts b/apps/frontend/src/hooks/useSearch.ts index 45f64ca..5e3c886 100644 --- a/apps/frontend/src/hooks/useSearch.ts +++ b/apps/frontend/src/hooks/useSearch.ts @@ -1,15 +1 @@ -import { useQuery } from '@tanstack/react-query'; -import { searchSecurities } from '@/shared/api/client'; -import type { SearchResultItem } from '@/shared/api/responses'; - -export function useSearch(query: string) { - return useQuery({ - queryKey: ['securities', 'search', query], - queryFn: async () => { - const res = await searchSecurities(query); - return res.data; - }, - enabled: query.length >= 2, - staleTime: 60_000, - }); -} +export { useSearch } from '@/entities/search'; diff --git a/apps/frontend/src/pages/BondPage.test.tsx b/apps/frontend/src/pages/BondPage.test.tsx index 2b38ce0..4435ce1 100644 --- a/apps/frontend/src/pages/BondPage.test.tsx +++ b/apps/frontend/src/pages/BondPage.test.tsx @@ -3,7 +3,7 @@ import { screen } from '@testing-library/react'; import { Routes, Route } from 'react-router-dom'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; -import { BondPage } from './BondPage'; +import { BondPage } from '@/pages/bond'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; diff --git a/apps/frontend/src/pages/BondPage.tsx b/apps/frontend/src/pages/BondPage.tsx index 25b4871..4b20ac7 100644 --- a/apps/frontend/src/pages/BondPage.tsx +++ b/apps/frontend/src/pages/BondPage.tsx @@ -1,33 +1 @@ -import { useParams } from 'react-router-dom'; -import { useBond, useBondCandles } from '../entities/bond'; -import { BondDetails } from '../components/BondDetails'; -import { PriceChart } from '../components/PriceChart'; - -export function BondPage() { - const { secid } = useParams<{ secid: string }>(); - const { data: bond, isLoading, error } = useBond(secid!); - const till = new Date().toISOString().split('T')[0]; - const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - const { data: candles } = useBondCandles(secid!, '24h', from, till); - - if (isLoading) return
Загрузка...
; - if (error || !bond) return
Инструмент не найден
; - - return ( -
- - -
-

График цены

- -
-
- ); -} +export { BondPage } from '@/pages/bond'; diff --git a/apps/frontend/src/pages/HomePage.test.tsx b/apps/frontend/src/pages/HomePage.test.tsx index bef45cd..6964234 100644 --- a/apps/frontend/src/pages/HomePage.test.tsx +++ b/apps/frontend/src/pages/HomePage.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { HomePage } from './HomePage'; +import { HomePage } from '@/pages/home'; describe('HomePage', () => { it('renders welcome title', () => { diff --git a/apps/frontend/src/pages/HomePage.tsx b/apps/frontend/src/pages/HomePage.tsx index a0f2c52..2de953a 100644 --- a/apps/frontend/src/pages/HomePage.tsx +++ b/apps/frontend/src/pages/HomePage.tsx @@ -1,14 +1 @@ -export function HomePage() { - return ( -
-

MoexVibe

-

- Анализ акций и облигаций Московской биржи -

-

Введите название или тикер в строку поиска выше

-

- Данные задерживаются на 15 минут · Бесплатный API MOEX ISS -

-
- ); -} +export { HomePage } from '@/pages/home'; diff --git a/apps/frontend/src/pages/StockPage.test.tsx b/apps/frontend/src/pages/StockPage.test.tsx index c89b55e..63b4fc3 100644 --- a/apps/frontend/src/pages/StockPage.test.tsx +++ b/apps/frontend/src/pages/StockPage.test.tsx @@ -3,7 +3,7 @@ import { screen, waitFor } from '@testing-library/react'; import { Routes, Route } from 'react-router-dom'; import { http, HttpResponse } from 'msw'; import { server } from '../test/server'; -import { StockPage } from './StockPage'; +import { StockPage } from '@/pages/stock'; import { renderWithProviders } from '../test/test-utils'; const API = '/api/v1'; diff --git a/apps/frontend/src/pages/StockPage.tsx b/apps/frontend/src/pages/StockPage.tsx index bfe90ce..5e65071 100644 --- a/apps/frontend/src/pages/StockPage.tsx +++ b/apps/frontend/src/pages/StockPage.tsx @@ -1,65 +1 @@ -import { useParams } from 'react-router-dom'; -import { useStock, useStockCandles, useStockDividends } from '../entities/stock'; -import { StockDetails } from '../components/StockDetails'; -import { PriceChart } from '../components/PriceChart'; - -export function StockPage() { - const { secid } = useParams<{ secid: string }>(); - const { data: stock, isLoading, error } = useStock(secid!); - const till = new Date().toISOString().split('T')[0]; - const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; - const { data: candles } = useStockCandles(secid!, '24h', from, till); - const { data: dividends } = useStockDividends(secid!); - - if (isLoading) return
Загрузка...
; - if (error || !stock) return
Инструмент не найден
; - - return ( -
- - -
-

График цены

- -
- - {dividends && dividends.length > 0 && ( -
-

Дивиденды

- - - - - - - - - {dividends.map((d, i) => ( - - - - - ))} - -
Дата закрытия реестраСумма
{d.registryCloseDate} - {d.value.toFixed(2)} {d.currency} -
-
- )} -
- ); -} +export { StockPage } from '@/pages/stock'; diff --git a/apps/frontend/src/pages/bond/index.ts b/apps/frontend/src/pages/bond/index.ts new file mode 100644 index 0000000..ccfcab4 --- /dev/null +++ b/apps/frontend/src/pages/bond/index.ts @@ -0,0 +1 @@ +export { BondPage } from './ui/BondPage'; diff --git a/apps/frontend/src/pages/bond/ui/BondPage.tsx b/apps/frontend/src/pages/bond/ui/BondPage.tsx new file mode 100644 index 0000000..62e2e15 --- /dev/null +++ b/apps/frontend/src/pages/bond/ui/BondPage.tsx @@ -0,0 +1,33 @@ +import { useParams } from 'react-router-dom'; +import { useBond, useBondCandles } from '@/entities/bond'; +import { BondDetails } from '@/widgets/bond-details'; +import { PriceChart } from '@/widgets/price-chart'; + +export function BondPage() { + const { secid } = useParams<{ secid: string }>(); + const { data: bond, isLoading, error } = useBond(secid!); + const till = new Date().toISOString().split('T')[0]; + const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const { data: candles } = useBondCandles(secid!, '24h', from, till); + + if (isLoading) return
Загрузка...
; + if (error || !bond) return
Инструмент не найден
; + + return ( +
+ + +
+

График цены

+ +
+
+ ); +} diff --git a/apps/frontend/src/pages/home/index.ts b/apps/frontend/src/pages/home/index.ts new file mode 100644 index 0000000..a410f01 --- /dev/null +++ b/apps/frontend/src/pages/home/index.ts @@ -0,0 +1 @@ +export { HomePage } from './ui/HomePage'; diff --git a/apps/frontend/src/pages/home/ui/HomePage.tsx b/apps/frontend/src/pages/home/ui/HomePage.tsx new file mode 100644 index 0000000..a0f2c52 --- /dev/null +++ b/apps/frontend/src/pages/home/ui/HomePage.tsx @@ -0,0 +1,14 @@ +export function HomePage() { + return ( +
+

MoexVibe

+

+ Анализ акций и облигаций Московской биржи +

+

Введите название или тикер в строку поиска выше

+

+ Данные задерживаются на 15 минут · Бесплатный API MOEX ISS +

+
+ ); +} diff --git a/apps/frontend/src/pages/stock/index.ts b/apps/frontend/src/pages/stock/index.ts new file mode 100644 index 0000000..cb139bc --- /dev/null +++ b/apps/frontend/src/pages/stock/index.ts @@ -0,0 +1 @@ +export { StockPage } from './ui/StockPage'; diff --git a/apps/frontend/src/pages/stock/ui/StockPage.tsx b/apps/frontend/src/pages/stock/ui/StockPage.tsx new file mode 100644 index 0000000..a41bdef --- /dev/null +++ b/apps/frontend/src/pages/stock/ui/StockPage.tsx @@ -0,0 +1,37 @@ +import { useParams } from 'react-router-dom'; +import { useStock, useStockCandles, useStockDividends } from '@/entities/stock'; +import { StockDetails } from '@/widgets/stock-details'; +import { PriceChart } from '@/widgets/price-chart'; +import { DividendsTable } from '@/widgets/dividends-table'; + +export function StockPage() { + const { secid } = useParams<{ secid: string }>(); + const { data: stock, isLoading, error } = useStock(secid!); + const till = new Date().toISOString().split('T')[0]; + const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const { data: candles } = useStockCandles(secid!, '24h', from, till); + const { data: dividends } = useStockDividends(secid!); + + if (isLoading) return
Загрузка...
; + if (error || !stock) return
Инструмент не найден
; + + return ( +
+ + +
+

График цены

+ +
+ + {dividends && dividends.length > 0 && } +
+ ); +} diff --git a/apps/frontend/src/widgets/bond-details/index.ts b/apps/frontend/src/widgets/bond-details/index.ts new file mode 100644 index 0000000..c8ebf2e --- /dev/null +++ b/apps/frontend/src/widgets/bond-details/index.ts @@ -0,0 +1 @@ +export { BondDetails } from './ui/BondDetails'; diff --git a/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx b/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx new file mode 100644 index 0000000..3c704c1 --- /dev/null +++ b/apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BondDetails } from './BondDetails'; +import { createMockBond } from '@/test/factories'; + +describe('BondDetails', () => { + it('renders bond details', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument(); + expect(screen.getAllByText('RU000A101XU7').length).toBe(2); + }); + + it('shows price as percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('98.50%')).toBeInTheDocument(); + }); + + it('renders maturity date', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('2027-05-15')).toBeInTheDocument(); + }); + + it('shows coupon with percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument(); + }); + + it('shows coupon without percentage when null', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + couponPercent: null, + }, + }); + render(); + expect(screen.getByText('36.9 ₽')).toBeInTheDocument(); + }); + + it('shows dash for missing next coupon date', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + nextCouponDate: null, + }, + }); + render(); + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it('shows dash for null yieldToMaturity', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + yieldToMaturity: null, + }, + }); + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('preserves unit suffixes for missing values', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + price: null, + couponValue: null, + couponPercent: 7.5, + accruedInt: null, + }, + }); + + render(); + + expect(screen.getByText('—%')).toBeInTheDocument(); + expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument(); + expect(screen.getByText('— ₽')).toBeInTheDocument(); + }); + + it('shows bond type', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ')).toBeInTheDocument(); + }); + + it('preserves percent suffix when price is missing', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + price: null, + }, + }); + render(); + expect(screen.getByText('—%')).toBeInTheDocument(); + }); + + it('preserves currency and percentage formatting when coupon value is missing', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + couponValue: null, + }, + }); + render(); + expect(screen.getByText('Купон')).toBeInTheDocument(); + expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument(); + }); + + it('preserves currency suffix when accrued interest is missing', () => { + const bond = createMockBond({ + marketData: { + ...createMockBond().marketData, + accruedInt: null, + }, + }); + render(); + expect(screen.getByText('НКД')).toBeInTheDocument(); + expect(screen.getByText('— ₽')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx b/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx new file mode 100644 index 0000000..dbf7588 --- /dev/null +++ b/apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx @@ -0,0 +1,83 @@ +import type { BondResponse } from '@/shared/api/responses'; + +interface BondDetailsProps { + bond: BondResponse; +} + +const rowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + padding: '8px 0', + borderBottom: '1px solid #eee', +}; + +export function BondDetails({ bond }: BondDetailsProps) { + const md = bond.marketData; + + return ( +
+
+

{bond.shortName}

+
{bond.isin}
+
+ +
+ {md.price?.toFixed(2) ?? '—'}% +
+ +
+
+ Номинал + + {bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit} + +
+
+ Дата погашения + {bond.matDate} +
+
+ Купон + + {md.couponValue ?? '—'} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''} + +
+
+ Период купона + {bond.couponPeriod} дней +
+
+ Следующий купон + {md.nextCouponDate ?? '—'} +
+
+ НКД + {md.accruedInt?.toFixed(2) ?? '—'} ₽ +
+
+ Доходность к погашению + {md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'} +
+
+ Дюрация + {md.duration != null ? md.duration.toFixed(2) : '—'} +
+
+ Тип + {bond.bondType} +
+
+ ISIN + {bond.isin} +
+
+
+ ); +} diff --git a/apps/frontend/src/widgets/dividends-table/index.ts b/apps/frontend/src/widgets/dividends-table/index.ts new file mode 100644 index 0000000..3ab7a09 --- /dev/null +++ b/apps/frontend/src/widgets/dividends-table/index.ts @@ -0,0 +1 @@ +export { DividendsTable } from './ui/DividendsTable'; diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx new file mode 100644 index 0000000..4b1203d --- /dev/null +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.test.tsx @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DividendsTable } from './DividendsTable'; +import { createMockDividends } from '@/test/factories'; + +describe('DividendsTable', () => { + it('renders title, date column and formatted amount with currency', () => { + render(); + + expect(screen.getByText('Дивиденды')).toBeInTheDocument(); + expect(screen.getByText('Дата закрытия реестра')).toBeInTheDocument(); + expect(screen.getByText('2024-07-10')).toBeInTheDocument(); + expect(screen.getByText('35.00 RUB')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx new file mode 100644 index 0000000..f127f55 --- /dev/null +++ b/apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx @@ -0,0 +1,38 @@ +import type { DividendItem } from '@/shared/api/responses'; + +interface DividendsTableProps { + dividends: DividendItem[]; +} + +export function DividendsTable({ dividends }: DividendsTableProps) { + return ( +
+

Дивиденды

+ + + + + + + + + {dividends.map((d, i) => ( + + + + + ))} + +
Дата закрытия реестраСумма
{d.registryCloseDate} + {d.value.toFixed(2)} {d.currency} +
+
+ ); +} diff --git a/apps/frontend/src/widgets/price-chart/index.ts b/apps/frontend/src/widgets/price-chart/index.ts new file mode 100644 index 0000000..0ecfa29 --- /dev/null +++ b/apps/frontend/src/widgets/price-chart/index.ts @@ -0,0 +1 @@ +export { PriceChart } from './ui/PriceChart'; diff --git a/apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx b/apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx new file mode 100644 index 0000000..fe00156 --- /dev/null +++ b/apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { PriceChart } from './PriceChart'; + +describe('PriceChart', () => { + it('renders chart container with empty data', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('renders with candle data', () => { + const data = [{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }]; + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('accepts custom height', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx b/apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx new file mode 100644 index 0000000..4df9440 --- /dev/null +++ b/apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; +import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts'; + +interface PriceChartProps { + data: Array<{ + open: number; + high: number; + low: number; + close: number; + begin: string; + }>; + height?: number; +} + +export function PriceChart({ data, height = 400 }: PriceChartProps) { + const chartContainerRef = useRef(null); + + useEffect(() => { + if (!chartContainerRef.current) return; + + const chart = createChart(chartContainerRef.current, { + layout: { + background: { type: ColorType.Solid, color: '#ffffff' }, + textColor: '#333', + }, + width: chartContainerRef.current.clientWidth, + height, + grid: { + vertLines: { color: '#f0f0f0' }, + horzLines: { color: '#f0f0f0' }, + }, + timeScale: { + timeVisible: false, + }, + }); + + const candleSeries = chart.addCandlestickSeries({ + upColor: '#2e7d32', + downColor: '#c62828', + borderDownColor: '#c62828', + borderUpColor: '#2e7d32', + wickDownColor: '#c62828', + wickUpColor: '#2e7d32', + }); + + const chartData: CandlestickData[] = data.map((candle) => ({ + time: (new Date(candle.begin).getTime() / 1000) as Time, + open: candle.open, + high: candle.high, + low: candle.low, + close: candle.close, + })); + + candleSeries.setData(chartData); + chart.timeScale().fitContent(); + + const handleResize = () => { + if (chartContainerRef.current) { + chart.applyOptions({ width: chartContainerRef.current.clientWidth }); + } + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chart.remove(); + }; + }, [data, height]); + + return
; +} diff --git a/apps/frontend/src/widgets/search-bar/index.ts b/apps/frontend/src/widgets/search-bar/index.ts new file mode 100644 index 0000000..042e3b7 --- /dev/null +++ b/apps/frontend/src/widgets/search-bar/index.ts @@ -0,0 +1 @@ +export { SearchBar } from './ui/SearchBar'; diff --git a/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx new file mode 100644 index 0000000..b37561c --- /dev/null +++ b/apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { server } from '@/test/server'; +import { SearchBar } from '@/widgets/search-bar'; + +const API = '/api/v1'; + +function renderSearchBar() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + + + , + ); +} + +describe('SearchBar', () => { + it('renders search input', () => { + renderSearchBar(); + + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); + }); + + it('shows dropdown on focus', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + + await userEvent.type(input, 'sber'); + + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + }); + + it('shows loading state while fetching', async () => { + server.use(http.get(`${API}/securities/search`, () => new Promise(() => {}))); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + + await userEvent.type(input, 'sber'); + + await waitFor(() => { + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + }); + + it('shows no results message', async () => { + server.use( + http.get(`${API}/securities/search`, () => + HttpResponse.json({ + data: { data: [], meta: { fromCache: false, cachedAt: null } }, + }), + ), + ); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + + await userEvent.type(input, 'zzzzz'); + + await waitFor(() => { + expect(screen.getByText('Ничего не найдено')).toBeInTheDocument(); + }); + }); + + it('hides dropdown when clicking outside', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + + await userEvent.type(input, 'sber'); + + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + + await userEvent.click(document.body); + + await waitFor(() => { + expect(screen.queryByText('Сбер')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx b/apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx new file mode 100644 index 0000000..240f04b --- /dev/null +++ b/apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx @@ -0,0 +1,113 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSearch } from '@/entities/search'; + +export function SearchBar() { + const [query, setQuery] = useState(''); + const [debounced, setDebounced] = useState(''); + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + const ref = useRef(null); + + useEffect(() => { + const id = setTimeout(() => setDebounced(query), 300); + + return () => clearTimeout(id); + }, [query]); + + const { data: results, isLoading } = useSearch(debounced); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + + document.addEventListener('mousedown', handleClick); + + return () => document.removeEventListener('mousedown', handleClick); + }, []); + + const showResults = open && debounced.length >= 2; + + return ( +
+ { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #ccc', + borderRadius: 6, + fontSize: 14, + }} + /> + {showResults && ( +
    + {isLoading &&
  • Загрузка...
  • } + {!isLoading && results && results.length === 0 && ( +
  • Ничего не найдено
  • + )} + {!isLoading && + results?.map((item) => ( +
  • { + setOpen(false); + setQuery(''); + navigate( + item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`, + ); + }} + style={{ + padding: '10px 12px', + cursor: 'pointer', + borderBottom: '1px solid #f0f0f0', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }} + onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')} + onMouseLeave={(e) => (e.currentTarget.style.background = '')} + > + + {item.shortName} + {item.secid} + + + {item.type === 'share' ? 'Акция' : 'Облигация'} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/widgets/stock-details/index.ts b/apps/frontend/src/widgets/stock-details/index.ts new file mode 100644 index 0000000..36b0b00 --- /dev/null +++ b/apps/frontend/src/widgets/stock-details/index.ts @@ -0,0 +1 @@ +export { StockDetails } from './ui/StockDetails'; diff --git a/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx b/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx new file mode 100644 index 0000000..203cea2 --- /dev/null +++ b/apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StockDetails } from './StockDetails'; +import { createMockShare } from '@/test/factories'; + +describe('StockDetails', () => { + it('renders stock details', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument(); + expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument(); + }); + + it('displays positive change in green', () => { + const stock = createMockShare({ + marketData: { + ...createMockShare().marketData, + change: 5, + changePercent: 2, + }, + }); + render(); + const changeEl = screen.getByText('+5.00 (2.00%)'); + expect(changeEl).toBeInTheDocument(); + }); + + it('displays negative change in red', () => { + const stock = createMockShare({ + marketData: { + ...createMockShare().marketData, + change: -3, + changePercent: -1, + }, + }); + render(); + const changeEl = screen.getByText('-3.00 (-1.00%)'); + expect(changeEl).toBeInTheDocument(); + }); + + it('shows price formatted', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('289,50')).toBeInTheDocument(); + }); + + it('shows dash for null high', () => { + const stock = createMockShare({ + marketData: { + ...createMockShare().marketData, + high: null, + }, + }); + render(); + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it('shows capitalization in billions', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument(); + }); + + it('falls back to zero change when change data is missing', () => { + const stock = createMockShare({ + marketData: { + ...createMockShare().marketData, + change: null, + changePercent: null, + }, + }); + render(); + expect(screen.getByText('289,50')).toBeInTheDocument(); + expect(screen.getByText('+0.00 (0.00%)')).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx b/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx new file mode 100644 index 0000000..dacdb6b --- /dev/null +++ b/apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx @@ -0,0 +1,85 @@ +import type { ShareResponse } from '@/shared/api/responses'; + +interface StockDetailsProps { + stock: ShareResponse; +} + +const rowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + padding: '8px 0', + borderBottom: '1px solid #eee', +}; + +export function StockDetails({ stock }: StockDetailsProps) { + const md = stock.marketData; + const change = md.change ?? 0; + const changePercent = md.changePercent ?? 0; + const isPositive = change >= 0; + const changeLabel = `${isPositive ? '+' : ''}${change.toFixed(2)} (${changePercent.toFixed(2)}%)`; + + return ( +
+
+

+ {stock.shortName} ({stock.secid}) +

+
+ {stock.name} · {stock.isin} +
+
+ +
+ {md.price?.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) ?? '—'}{' '} + + {changeLabel} + +
+ +
+
+ Открытие + {md.open?.toFixed(2) ?? '—'} +
+
+ Максимум + {md.high?.toFixed(2) ?? '—'} +
+
+ Минимум + {md.low?.toFixed(2) ?? '—'} +
+
+ Объём + {md.volume.toLocaleString('ru-RU')} +
+
+ Капитализация + + {md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'} + +
+
+ ISIN + {stock.isin} +
+
+ Уровень листинга + {stock.listLevel} +
+
+
+ ); +} diff --git a/docs/features/frontend-fsd-market-pages/plan.md b/docs/features/frontend-fsd-market-pages/plan.md new file mode 100644 index 0000000..84bebff --- /dev/null +++ b/docs/features/frontend-fsd-market-pages/plan.md @@ -0,0 +1,194 @@ +# Frontend FSD Market Pages 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:** Перевести market pages (`home`, `stock`, `bond`) и связанные market widgets в FSD-структуру без изменения пользовательского поведения и с сохранением совместимости через shim-файлы. + +**Architecture:** Route params и orchestration загрузки данных остаются в `pages/*`, а UI-композиция переносится в `widgets/*` с явным public API. Search query-hook выделяется в отдельный `entities/search` slice, старые entrypoints в `components/`, `hooks/` и плоских `pages/` превращаются в re-export shims, чтобы маршруты и тесты можно было переключать постепенно. + +**Tech Stack:** React 18, TypeScript, React Router v6, TanStack Query v5, lightweight-charts v4, Vitest, Testing Library, ESLint, Vite, Docusaurus. + +--- + +## Базовое состояние + +- Feature branch: `codex/frontend-fsd-market-pages` +- Baseline verification: + - `npm test -w apps/frontend` — PASS (`36` test files, `198` tests) + - `npm run lint -w apps/frontend` — PASS + - `npm run build -w apps/frontend` — PASS + +## Карта файлов + +### Create + +- `apps/frontend/src/entities/search/index.ts` — public API search slice +- `apps/frontend/src/entities/search/model/useSearch.ts` — query hook для поиска бумаг +- `apps/frontend/src/entities/search/model/useSearch.test.tsx` — тесты query hook после переноса +- `apps/frontend/src/widgets/search-bar/index.ts` — public API search widget +- `apps/frontend/src/widgets/search-bar/ui/SearchBar.tsx` — UI поиска с локальным state/dropdown +- `apps/frontend/src/widgets/search-bar/ui/SearchBar.test.tsx` — widget tests после переноса +- `apps/frontend/src/widgets/price-chart/index.ts` — public API chart widget +- `apps/frontend/src/widgets/price-chart/ui/PriceChart.tsx` — chart widget implementation +- `apps/frontend/src/widgets/price-chart/ui/PriceChart.test.tsx` — chart tests после переноса +- `apps/frontend/src/widgets/stock-details/index.ts` — public API stock details widget +- `apps/frontend/src/widgets/stock-details/ui/StockDetails.tsx` — stock details widget +- `apps/frontend/src/widgets/stock-details/ui/StockDetails.test.tsx` — widget tests после переноса +- `apps/frontend/src/widgets/bond-details/index.ts` — public API bond details widget +- `apps/frontend/src/widgets/bond-details/ui/BondDetails.tsx` — bond details widget +- `apps/frontend/src/widgets/bond-details/ui/BondDetails.test.tsx` — widget tests после переноса +- `apps/frontend/src/widgets/dividends-table/index.ts` — public API dividends widget +- `apps/frontend/src/widgets/dividends-table/ui/DividendsTable.tsx` — dividends table extracted from StockPage +- `apps/frontend/src/pages/home/index.ts` — public API home page +- `apps/frontend/src/pages/home/ui/HomePage.tsx` — home page entrypoint +- `apps/frontend/src/pages/stock/index.ts` — public API stock page +- `apps/frontend/src/pages/stock/ui/StockPage.tsx` — stock page entrypoint/orchestration +- `apps/frontend/src/pages/bond/index.ts` — public API bond page +- `apps/frontend/src/pages/bond/ui/BondPage.tsx` — bond page entrypoint/orchestration + +### Modify + +- `apps/frontend/src/app/routing/AppRoutes.tsx` — переключить route imports на `@/pages/home`, `@/pages/stock`, `@/pages/bond` +- `apps/frontend/src/app/layouts/AppLayout.tsx` — переключить импорт SearchBar на `@/widgets/search-bar` +- `apps/frontend/src/components/SearchBar.tsx` — re-export shim +- `apps/frontend/src/components/SearchBar.test.tsx` — перенести/обновить импорт под новый widget path +- `apps/frontend/src/components/PriceChart.tsx` — re-export shim +- `apps/frontend/src/components/PriceChart.test.tsx` — перенести/обновить импорт под новый widget path +- `apps/frontend/src/components/StockDetails.tsx` — re-export shim +- `apps/frontend/src/components/StockDetails.test.tsx` — перенести/обновить импорт под новый widget path +- `apps/frontend/src/components/BondDetails.tsx` — re-export shim +- `apps/frontend/src/components/BondDetails.test.tsx` — перенести/обновить импорт под новый widget path +- `apps/frontend/src/hooks/useSearch.ts` — re-export shim +- `apps/frontend/src/hooks/useSearch.test.tsx` — перенести/обновить импорт под новый entity path +- `apps/frontend/src/pages/HomePage.tsx` — re-export shim +- `apps/frontend/src/pages/HomePage.test.tsx` — перенести/обновить импорт под новый page path +- `apps/frontend/src/pages/StockPage.tsx` — re-export shim +- `apps/frontend/src/pages/StockPage.test.tsx` — перенести/обновить импорт под новый page path +- `apps/frontend/src/pages/BondPage.tsx` — re-export shim +- `apps/frontend/src/pages/BondPage.test.tsx` — перенести/обновить импорт под новый page path +- `apps/docs/docs/frontend/overview.md` — отразить FSD migration market pages +- `apps/docs/docs/frontend/components.md` — обновить описание widget/component boundaries +- `apps/docs/docs/frontend/hooks.md` — убрать market search из legacy hooks как primary location +- `apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md` — зафиксировать стратегию второй market-итерации FSD + +## Целевая структура + +```text +apps/frontend/src/ +├── entities/ +│ └── search/ +│ ├── index.ts +│ └── model/ +│ ├── useSearch.ts +│ └── useSearch.test.tsx +├── widgets/ +│ ├── search-bar/ +│ │ ├── index.ts +│ │ └── ui/ +│ │ ├── SearchBar.tsx +│ │ └── SearchBar.test.tsx +│ ├── price-chart/ +│ ├── stock-details/ +│ ├── bond-details/ +│ └── dividends-table/ +└── pages/ + ├── home/ + │ ├── index.ts + │ └── ui/HomePage.tsx + ├── stock/ + │ ├── index.ts + │ └── ui/StockPage.tsx + └── bond/ + ├── index.ts + └── ui/BondPage.tsx +``` + +## Потоки данных + +### Stock page + +```text +Route /stocks/:secid +→ pages/stock/ui/StockPage.tsx +→ useStock(secid) + useStockCandles(secid, interval, from, till) + useStockDividends(secid) +→ widgets/stock-details + widgets/price-chart + widgets/dividends-table +``` + +### Bond page + +```text +Route /bonds/:secid +→ pages/bond/ui/BondPage.tsx +→ useBond(secid) + useBondCandles(secid, interval, from, till) +→ widgets/bond-details + widgets/price-chart +``` + +### Search flow + +```text +AppLayout +→ widgets/search-bar/ui/SearchBar.tsx +→ entities/search/model/useSearch(debouncedQuery) +→ shared/api/client.searchSecurities(query) +→ navigate('/stocks/:secid' | '/bonds/:secid') +``` + +## Этапы реализации + +### Phase 1: Search entity и SearchBar widget + +1. Создать `entities/search/model/useSearch.ts`, перенеся текущий hook из `src/hooks/useSearch.ts` без изменения `queryKey`, `enabled`, `staleTime` и response mapping. +2. Создать `entities/search/index.ts` и экспортировать `useSearch` только через public API. +3. Перенести `SearchBar.tsx` в `widgets/search-bar/ui/SearchBar.tsx`, сохранив локальное состояние `query`, `debounced`, `open`, обработчик клика вне dropdown и navigate-логику. +4. Добавить `widgets/search-bar/index.ts`. +5. Старые `src/hooks/useSearch.ts` и `src/components/SearchBar.tsx` превратить в re-export shims. +6. Переместить и адаптировать тесты `useSearch.test.tsx` и `SearchBar.test.tsx` так, чтобы они проверяли новые public entrypoints. +7. Переключить `app/layouts/AppLayout.tsx` на импорт `@/widgets/search-bar`. + +### Phase 2: Market widgets + +1. Перенести `PriceChart.tsx` в `widgets/price-chart/ui/PriceChart.tsx` без изменения работы с `lightweight-charts`. +2. Перенести `StockDetails.tsx` и `BondDetails.tsx` в `widgets/stock-details/ui/StockDetails.tsx` и `widgets/bond-details/ui/BondDetails.tsx`. +3. Создать `widgets/dividends-table/ui/DividendsTable.tsx`, выделив таблицу дивидендов из `StockPage.tsx` без изменения разметки, заголовков и форматирования значений. +4. Добавить `index.ts` для каждого widget slice. +5. Старые `src/components/PriceChart.tsx`, `StockDetails.tsx`, `BondDetails.tsx` превратить в re-export shims. +6. Перенести widget tests рядом с новыми реализациями и оставить старые тестовые файлы либо как shim-import consumers, либо обновить их на новые public entrypoints без дублирования покрытия. + +### Phase 3: Market pages и route imports + +1. Создать `pages/home/ui/HomePage.tsx` и `pages/home/index.ts`, сохранив текущее статическое содержимое главной страницы. +2. Создать `pages/stock/ui/StockPage.tsx` и `pages/stock/index.ts`, оставив в page orchestration: + - чтение `secid` из `useParams` + - расчёт `from`/`till` + - вызовы `useStock`, `useStockCandles`, `useStockDividends` + - состояния loading/not-found + - передачу готовых props в widgets +3. Создать `pages/bond/ui/BondPage.tsx` и `pages/bond/index.ts` по той же схеме для `useBond` и `useBondCandles`. +4. Переключить `app/routing/AppRoutes.tsx` на новые page public entrypoints. +5. Старые `src/pages/HomePage.tsx`, `StockPage.tsx`, `BondPage.tsx` превратить в re-export shims. +6. Обновить page tests, чтобы они импортировали новые page entrypoints и подтверждали эквивалентность поведения. + +### Phase 4: Documentation, ADR и финальная verification + +1. Создать `apps/docs/docs/adr/ADR-014-frontend-fsd-market-pages.md` с секциями Context / Options / Decision / Consequences. +2. Обновить `apps/docs/docs/frontend/overview.md`, `components.md`, `hooks.md`, чтобы опубликованная документация отражала market FSD slices и shim-стратегию coexistence. +3. Проверить, что новые public API не требуют deep imports из `model/` или `ui/` во внешнем коде. +4. Выполнить финальную verification: + - `npm test -w apps/frontend` + - `npm run lint -w apps/frontend` + - `npm run build -w apps/frontend` + - `npm run build -w apps/docs` + +## Риски и контрольные точки + +- `PriceChart` чувствителен к ref/effect lifecycle. При переносе важно не менять cleanup и порядок инициализации chart series. +- `SearchBar` используется в `AppLayout`, поэтому ошибки в импорт-пути сразу затронут почти все route-level tests. +- `StockPage`/`BondPage` вычисляют даты inline. В этой фиче это остаётся в page-layer и не выносится в shared util, чтобы не расширять scope. +- Legacy shims должны оставаться тонкими re-export файлами без дополнительной логики. + +## Критерии завершения плана + +- Все acceptance criteria из `spec.md` сопоставлены задачам реализации. +- Новая market-структура использует только public API между `pages`, `widgets` и `entities`. +- Frontend tests, lint и build проходят после миграции. +- ADR и frontend docs синхронизированы с новой структурой. diff --git a/docs/features/frontend-fsd-market-pages/spec.md b/docs/features/frontend-fsd-market-pages/spec.md new file mode 100644 index 0000000..da77348 --- /dev/null +++ b/docs/features/frontend-fsd-market-pages/spec.md @@ -0,0 +1,125 @@ +# Frontend FSD Market Pages + +Дата: 2026-06-20 +Статус: спецификация + +## Контекст + +Предыдущие FSD-итерации мигрировали broker-домен (`frontend-fsd-broker-pilot`), shared-слой +(`frontend-fsd-shared-layer`), entities stock/bond/portfolio (`fsd-entities-migration`) и app-слой с +auth (`frontend-fsd-app-auth`). + +Рыночные страницы (StockPage, BondPage, HomePage) и их компоненты (StockDetails, BondDetails, +PriceChart, SearchBar) остались в исторической технической структуре (`components/`, `hooks/`, +плоские `pages/`). Это единственный крупный блок кода вне FSD-структуры, который активно +используется пользователями. + +## Иерархия источников + +- Текущая задача пользователя: завершить FSD-миграцию рыночных страниц. +- `docs/inbox.md`: направление на постепенную FSD-миграцию. +- `docs/features/frontend-fsd-broker-pilot/spec.md`: эталонный FSD-шаблон. + +## Цель + +Перевести рыночные страницы (stock, bond, home) и связанные компоненты в FSD-структуру, следуя +паттерну, установленному broker-пилотом — через widgets, entities и page entrypoints с +совместимыми shim-файлами. + +## Область изменений + +Фича охватывает только frontend-код рыночных страниц: + +- StockPage, BondPage, HomePage — миграция из плоских `pages/` в `pages/stock/`, `pages/bond/`, + `pages/home/` с FSD-структурой; +- StockDetails, BondDetails — миграция из `components/` в `widgets/stock-details/`, + `widgets/bond-details/`; +- PriceChart — миграция из `components/` в `widgets/price-chart/`; +- SearchBar + useSearch — миграция в `widgets/search-bar/` + новый entity `entities/search/`; +- таблица дивидендов — вынос из StockPage в новый виджет `widgets/dividends-table/`; +- shim-файлы для обратной совместимости; +- обновление роутинга и layout для импортов из новых FSD-точек входа. + +## Требования + +### 1. Все компоненты следуют FSD-слоям + +- entities — data-слой (API + query hooks). Новый: `entities/search/`. +- widgets — бизнес-композиция UI. Новые: `widgets/stock-details/`, `widgets/bond-details/`, + `widgets/price-chart/`, `widgets/search-bar/`, `widgets/dividends-table/`. +- pages — тонкие entrypoints. Новые: `pages/stock/`, `pages/bond/`, `pages/home/`. + +### 1.1 Ownership данных остаётся у pages и entities + +- `pages/stock` и `pages/bond` владеют route params и orchestration загрузки данных через + существующие entity hooks (`useStock`, `useBond`, `useStockCandles`, `useBondCandles`, + `useStockDividends`). +- `widgets/stock-details`, `widgets/bond-details`, `widgets/price-chart`, + `widgets/dividends-table` остаются презентационно-композиционными и получают готовые данные + через props. +- `widgets/search-bar` владеет локальным UI-состоянием поиска (input, debounced query, dropdown), + а query-hook `useSearch` размещается в `entities/search/model/`. +- `pages/home` остаётся статическим page entrypoint без новой data-логики. + +### 2. Границы срезов явные через public API + +Каждый widget и entity имеет `index.ts` barrel с контролируемым экспортом. + +Внешний код импортирует market pages/widgets/entities только через их public API +(`@/pages/stock`, `@/widgets/search-bar`, `@/entities/search` и т.д.). Deep imports во внутренние +`model/`, `api/`, `ui/` директории не считаются контрактом для внешних потребителей. + +### 3. Поведение UI не меняется + +Миграция не добавляет новую функциональность и не пересматривает UX. + +### 4. Совместимость через shim-файлы + +Старые пути (`components/StockDetails.tsx`, `hooks/useSearch.ts`, `pages/StockPage.tsx`) становятся +re-export shim-файлами, импортирующими из новых FSD-точек входа. + +### 5. Тесты следуют новым границам + +Тесты перемещаются вместе с компонентами в соответствующие widget/page/ui-директории. + +### 6. Общая инфраструктура остаётся в shared + +HTTP-клиент и типы остаются в `shared/api/`. PriceChart использует `lightweight-charts` +непосредственно, без дополнительных абстракций. + +## Ограничения + +- Изменения ограничены frontend-пакетом. +- Допускаются связанные обновления опубликованной frontend-документации и ADR, если они отражают + новую FSD-структуру market pages. +- Не вводятся ESLint import boundaries или import guards. +- Не мигрируются portfolio и screener страницы. +- Не удаляется мёртвый код (old `pages/broker/`, `api/broker.ts`). +- Не меняется backend API, Swagger, codegen. + +## Acceptance Criteria + +- StockDetails доступен через `widgets/stock-details`. +- BondDetails доступен через `widgets/bond-details`. +- PriceChart доступен через `widgets/price-chart`. +- SearchBar доступен через `widgets/search-bar` и использует query/public API из `entities/search`. +- DividendsTable вынесен из StockPage в `widgets/dividends-table`. +- StockPage доступен через `pages/stock`. +- BondPage доступен через `pages/bond`. +- HomePage доступен через `pages/home`. +- Все старые пути (components/*, hooks/useSearch, pages/StockPage/BondPage/HomePage) — + re-export shims, импортирующие из новых FSD-точек входа. +- `app/routing/AppRoutes.tsx` и `app/layouts/AppLayout.tsx` импортируют из новых FSD-точек входа. +- `pages/stock` и `pages/bond` сохраняют orchestration загрузки market data и передают widgets + готовые props без переноса route/query orchestration внутрь widgets. +- Frontend lint, tests и build проходят. +- Пользовательское поведение эквивалентно. +- ADR и документация обновлены. + +## Не цели + +- Миграция portfolio, screener и остальных доменов. +- Введение жёстких правил импортов. +- Удаление мёртвого кода (old `pages/broker/`, `api/broker.ts`). +- Переписывание стилей или визуального дизайна. +- Добавление новой функциональности. diff --git a/docs/features/frontend-fsd-market-pages/tasks.md b/docs/features/frontend-fsd-market-pages/tasks.md new file mode 100644 index 0000000..20e1351 --- /dev/null +++ b/docs/features/frontend-fsd-market-pages/tasks.md @@ -0,0 +1,45 @@ +# Frontend FSD Market Pages — задачи + +Статус: выполнено + +Подробные шаги, карта файлов и verification-команды находятся в [plan.md](plan.md). + +Финальная verification: +- `npm test -w apps/frontend` — PASS (`42` files, `230` tests) +- `npm run lint -w apps/frontend` — PASS +- `npm run build -w apps/frontend` — PASS +- `npm run build -w apps/docs` — PASS + +Deep import verification: +- `rg -n "@/entities/search/model|@/widgets/search-bar/ui|@/widgets/price-chart/ui|@/widgets/stock-details/ui|@/widgets/bond-details/ui|@/widgets/dividends-table/ui|@/pages/home/ui|@/pages/stock/ui|@/pages/bond/ui" apps/frontend/src apps/docs/docs` — no matches + +## 1. Search slice + +- [x] Создать `entities/search` с `useSearch` и public API. +- [x] Перенести `SearchBar` в `widgets/search-bar`. +- [x] Оставить `hooks/useSearch.ts` и `components/SearchBar.tsx` как re-export shims. +- [x] Переключить `AppLayout` на `@/widgets/search-bar`. +- [x] Прогнать `useSearch` и `SearchBar` тесты после переноса. + +## 2. Market widgets + +- [x] Перенести `PriceChart` в `widgets/price-chart`. +- [x] Перенести `StockDetails` в `widgets/stock-details`. +- [x] Перенести `BondDetails` в `widgets/bond-details`. +- [x] Вынести таблицу дивидендов в `widgets/dividends-table`. +- [x] Привязать widget tests к новым slice boundaries. + +## 3. Market pages + +- [x] Создать `pages/home`, `pages/stock`, `pages/bond` с public API. +- [x] Сохранить orchestration route params и data loading в `pages/stock` и `pages/bond`. +- [x] Переключить `AppRoutes` на новые page entrypoints. +- [x] Оставить `pages/HomePage.tsx`, `StockPage.tsx`, `BondPage.tsx` как re-export shims. +- [x] Прогнать page tests после переключения маршрутов. + +## 4. Docs и verification + +- [x] Добавить ADR по market FSD migration. +- [x] Обновить frontend docs (`overview.md`, `components.md`, `hooks.md`, `routes.md`). +- [x] Проверить отсутствие внешних deep imports в новые `model/` и `ui/` директории. +- [x] Выполнить финальную проверку: frontend `test`, `lint`, `build`, docs `build`.