Compare commits
2 Commits
af135d5640
...
5b9d7f3a27
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b9d7f3a27 | |||
| dca8418843 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@ vite.config.d.ts
|
||||
vite.config.js
|
||||
apps/docs/.docusaurus/
|
||||
apps/docs/build/
|
||||
.idea
|
||||
|
||||
228
AGENTS.md
228
AGENTS.md
@ -11,6 +11,231 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче.
|
||||
- **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере.
|
||||
|
||||
# Процесс разработки
|
||||
|
||||
Проект использует подход Specification-Driven Development (SDD).
|
||||
|
||||
## Структура документации
|
||||
|
||||
```text
|
||||
docs/
|
||||
|
||||
├── inbox.md
|
||||
├── roadmap.md
|
||||
│
|
||||
├── research/
|
||||
│
|
||||
├── epics/
|
||||
│ └── {epic-name}.md
|
||||
│
|
||||
└── features/
|
||||
└── {feature-name}/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
└── tasks.md
|
||||
|
||||
```
|
||||
|
||||
## Назначение документов
|
||||
### inbox.md
|
||||
|
||||
Содержит идеи и мысли, которые появились во время работы над проектом.
|
||||
|
||||
Записи в inbox не являются требованиями и не должны реализовываться напрямую.
|
||||
|
||||
### roadmap.md
|
||||
|
||||
Содержит список запланированных эпиков и фич.
|
||||
|
||||
Наличие задачи в roadmap не означает, что её нужно немедленно реализовать.
|
||||
|
||||
### research/
|
||||
|
||||
Содержит результаты исследований и экспериментов.
|
||||
|
||||
Документы могут содержать гипотезы, предположения и открытые вопросы.
|
||||
|
||||
Результаты исследований необходимо проверять перед реализацией.
|
||||
|
||||
### epics/
|
||||
|
||||
Эпик представляет собой крупную продуктовую возможность или модуль.
|
||||
|
||||
Эпик может состоять из нескольких фич.
|
||||
|
||||
### features/{feature-name}/spec.md
|
||||
|
||||
Описывает ЧТО должно быть реализовано.
|
||||
|
||||
Спецификация должна содержать:
|
||||
- цель
|
||||
- требования
|
||||
- ограничения
|
||||
- критерии приемки (Acceptance Criteria)
|
||||
|
||||
Спецификация не должна содержать деталей реализации.
|
||||
|
||||
### features/{feature-name}/plan.md
|
||||
|
||||
Описывает КАК будет реализована фича.
|
||||
|
||||
План может содержать:
|
||||
- архитектурные решения
|
||||
- API контракты
|
||||
- потоки данных
|
||||
- технический подход
|
||||
|
||||
### features/{feature-name}/tasks.md
|
||||
|
||||
Содержит список задач для реализации.
|
||||
|
||||
Задачи должны быть:
|
||||
- небольшими
|
||||
- конкретными
|
||||
- независимыми по возможности
|
||||
|
||||
|
||||
## Правила разработки
|
||||
|
||||
|
||||
### Правило 1
|
||||
|
||||
Нельзя начинать реализацию без спецификации.
|
||||
|
||||
Если спецификации нет:
|
||||
|
||||
- Провести исследование при необходимости.
|
||||
- Создать spec.md.
|
||||
- Уточнить требования.
|
||||
- Только после этого переходить к реализации.
|
||||
|
||||
### Правило 2
|
||||
|
||||
Реализация должна соответствовать spec.md.
|
||||
|
||||
Если в процессе разработки выясняется, что требования неполные или ошибочные:
|
||||
|
||||
Не изменять поведение системы молча.
|
||||
|
||||
Сначала обновить:
|
||||
- spec.md
|
||||
- plan.md
|
||||
|
||||
И только потом продолжать реализацию.
|
||||
|
||||
### Правило 3
|
||||
|
||||
Спецификация является источником истины.
|
||||
|
||||
Если plan.md противоречит spec.md:
|
||||
Приоритет имеет spec.md.
|
||||
|
||||
### Правило 4
|
||||
|
||||
Не добавлять функциональность, которая отсутствует в спецификации.
|
||||
|
||||
Если появилась новая идея:
|
||||
|
||||
- обновить спецификацию;
|
||||
- либо создать новую фичу.
|
||||
|
||||
### Правило 5
|
||||
|
||||
Исправления ошибок можно выполнять напрямую.
|
||||
|
||||
Новая функциональность должна проходить через спецификацию.
|
||||
|
||||
|
||||
## Процесс работы над фичей
|
||||
|
||||
При реализации фичи необходимо:
|
||||
|
||||
1) Ознакомиться с эпиком, если он существует.
|
||||
2) Прочитать spec.md.
|
||||
3) Прочитать plan.md.
|
||||
4) Прочитать tasks.md.
|
||||
5) Выполнять задачи последовательно.
|
||||
6) Отмечать выполненные задачи.
|
||||
7) Обновлять plan.md при изменении технических решений.
|
||||
8) Обновлять spec.md при изменении требований.
|
||||
|
||||
## Работа с новыми идеями
|
||||
|
||||
Если во время реализации появилась новая идея:
|
||||
|
||||
Не реализовывать её автоматически.
|
||||
|
||||
Необходимо определить, является ли она:
|
||||
- багом;
|
||||
- улучшением существующей функциональности;
|
||||
- новой фичей.
|
||||
|
||||
Если это улучшение или новая фича:
|
||||
|
||||
Добавить её в:
|
||||
- inbox.md
|
||||
|
||||
или создать отдельную фичу.
|
||||
|
||||
## Работа с существующими фичами
|
||||
|
||||
Улучшения существующей функциональности обычно остаются внутри текущего эпика.
|
||||
|
||||
Пример:
|
||||
|
||||
Portfolio Dashboard
|
||||
- История операций
|
||||
- Пагинация истории операций
|
||||
- Фильтрация истории операций
|
||||
- Экспорт истории операций
|
||||
|
||||
Все перечисленные возможности относятся к одному эпику.
|
||||
|
||||
Новый эпик создаётся только при появлении новой продуктовой возможности или нового домена.
|
||||
|
||||
## Поддержание документации
|
||||
|
||||
Документация должна соответствовать текущему состоянию проекта.
|
||||
|
||||
После значимых изменений необходимо обновлять:
|
||||
- spec.md
|
||||
- plan.md
|
||||
- tasks.md
|
||||
- ADR
|
||||
- архитектурную документацию
|
||||
|
||||
Документация не должна отставать от реализации.
|
||||
|
||||
## Поведение AI-агентов
|
||||
|
||||
Перед написанием кода необходимо:
|
||||
1) Изучить спецификацию фичи.
|
||||
2) Проверить полноту требований.
|
||||
3) Найти неоднозначности и противоречия.
|
||||
4) При необходимости запросить уточнения.
|
||||
|
||||
Запрещено:
|
||||
- придумывать требования;
|
||||
- додумывать поведение системы;
|
||||
- реализовывать неописанную функциональность.
|
||||
|
||||
Если информации недостаточно:
|
||||
- Остановиться и запросить уточнение вместо того, чтобы делать предположения.
|
||||
|
||||
## Приоритет источников информации
|
||||
|
||||
При возникновении противоречий использовать следующий порядок приоритетов:
|
||||
|
||||
1. Текущая задача пользователя.
|
||||
2. spec.md фичи.
|
||||
3. plan.md фичи.
|
||||
4. ADR.
|
||||
5. Архитектурная документация.
|
||||
6. roadmap.md.
|
||||
7. inbox.md.
|
||||
|
||||
roadmap.md и inbox.md никогда не являются основанием для реализации функциональности.
|
||||
|
||||
## Git workflow
|
||||
|
||||
- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё.
|
||||
@ -20,11 +245,8 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
## Документация и SDD-артефакты
|
||||
|
||||
- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus).
|
||||
- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`.
|
||||
- Все SDD spec-файлы в `docs/superpowers/specs/` пишутся на русском языке; англоязычные термины допустимы для API, кода, протоколов и официальных названий.
|
||||
- ADR для опубликованной документации находятся в `apps/docs/docs/adr/`.
|
||||
- OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`.
|
||||
- Superpowers plans и временные execution logs не коммитить по умолчанию. Если нужен план для ревью, держать его кратким и переносить устойчивые решения в spec/ADR/docs.
|
||||
|
||||
## Команды
|
||||
|
||||
|
||||
@ -54,5 +54,8 @@ apps/
|
||||
frontend/ — React SPA with Vite
|
||||
docs/ — Docusaurus documentation site
|
||||
docs/
|
||||
superpowers/specs/ — accepted SDD specifications
|
||||
features/ — SDD feature specifications and implementation plans
|
||||
epics/ — product epics
|
||||
inbox.md — captured ideas and follow-ups
|
||||
roadmap.md — planned epics and features
|
||||
```
|
||||
|
||||
4
docs/epics/Authentication.md
Normal file
4
docs/epics/Authentication.md
Normal file
@ -0,0 +1,4 @@
|
||||
Авторизация
|
||||
|
||||
Features:
|
||||
- auth-system
|
||||
8
docs/epics/BrokerPortfolio.md
Normal file
8
docs/epics/BrokerPortfolio.md
Normal file
@ -0,0 +1,8 @@
|
||||
Портфель брокера
|
||||
|
||||
Features:
|
||||
- broker-operations-ui-improvements
|
||||
- broker-portfolio-display
|
||||
- broker-positions-pagination-and-loading
|
||||
- tbank-broker-portfolios
|
||||
- tbank-deadline-queue-fix
|
||||
5
docs/epics/DevOps.md
Normal file
5
docs/epics/DevOps.md
Normal file
@ -0,0 +1,5 @@
|
||||
Developer Operations
|
||||
|
||||
Features:
|
||||
- ci-cd
|
||||
- pre-commit-checks
|
||||
5
docs/epics/Documentation.md
Normal file
5
docs/epics/Documentation.md
Normal file
@ -0,0 +1,5 @@
|
||||
Документация
|
||||
|
||||
Features:
|
||||
- docusaurus-docs
|
||||
- russian-docs-and-architecture-diagrams
|
||||
9
docs/epics/PortfolioDasboard.md
Normal file
9
docs/epics/PortfolioDasboard.md
Normal file
@ -0,0 +1,9 @@
|
||||
Страница портфеля
|
||||
|
||||
Features:
|
||||
- pagination-loading-overlay
|
||||
- portfolio
|
||||
- portfolio-allocation-chart
|
||||
- portfolio-analytics
|
||||
- portfolio-enricher-optimization
|
||||
- portfolio-list-enrichment
|
||||
5
docs/epics/QualityAssurance.md
Normal file
5
docs/epics/QualityAssurance.md
Normal file
@ -0,0 +1,5 @@
|
||||
Контроль качества
|
||||
|
||||
Features:
|
||||
- frontend-test-coverage
|
||||
- quality-gate-contract-docs
|
||||
1186
docs/features/auth-system/plan.md
Normal file
1186
docs/features/auth-system/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
110
docs/features/ci-cd/plan.md
Normal file
110
docs/features/ci-cd/plan.md
Normal file
@ -0,0 +1,110 @@
|
||||
# CI/CD 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:** Add Gitea Actions CI pipeline with lint, test, and build for the MoexVibe monorepo.
|
||||
|
||||
**Architecture:** Single `.gitea/workflows/ci.yml` file with three parallel jobs (lint, test, build) triggered on push/PR to main. One script addition to root `package.json` for format checking.
|
||||
|
||||
**Tech Stack:** Gitea Actions (GitHub Actions-compatible YAML), Node.js 20, npm workspaces
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `format:check` script to root package.json
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (root)
|
||||
|
||||
- [ ] **Step 1: Read root package.json**
|
||||
|
||||
- [ ] **Step 2: Add format:check script**
|
||||
|
||||
Edit `package.json`: add `"format:check": "prettier --check \"**/*.{ts,tsx}\""` to the `scripts` section, after `format`.
|
||||
|
||||
- [ ] **Step 3: Verify the script runs**
|
||||
|
||||
Run: `npm run format:check`
|
||||
Expected: exits 0 (all files already formatted) or lists formatting errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "ci: add format:check script for CI pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Gitea Actions workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: Create workflow directory**
|
||||
|
||||
Run: `mkdir -p .gitea/workflows`
|
||||
|
||||
- [ ] **Step 2: Create ci.yml with full pipeline**
|
||||
|
||||
Create `.gitea/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npx prettier --check "**/*.{ts,tsx}"
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:backend
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build:backend
|
||||
- run: npm run build:frontend
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/ci.yml
|
||||
git commit -m "ci: add Gitea Actions pipeline with lint, test, build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Verification
|
||||
|
||||
После пуша в `main` (или создания PR) проверить на https://git.ksv741.keenetic.pro/moex/moex-vibe/actions что pipeline запустился и все 3 job'а зелёные.
|
||||
@ -19,7 +19,7 @@
|
||||
- `docker/Dockerfile.*`, `docker/nginx.conf` → инфраструктура
|
||||
- `docker-compose.yml` → сервисы, порты
|
||||
- `.gitea/workflows/ci.yml` → CI pipeline
|
||||
- `docs/architecture/adr/*` → копия существующих ADR
|
||||
- `apps/docs/docs/adr/*` → опубликованные ADR проекта
|
||||
- `.prettierrc`, `tsconfig.base.json` → конфиги
|
||||
|
||||
## Pages
|
||||
1920
docs/features/frontend-test-coverage/plan.md
Normal file
1920
docs/features/frontend-test-coverage/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
3457
docs/features/moex-vibe/plan.md
Normal file
3457
docs/features/moex-vibe/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -260,14 +260,14 @@ moex-vibe/
|
||||
│ │ └── main.tsx
|
||||
│ └── package.json
|
||||
├── docs/
|
||||
│ ├── superpowers/specs/
|
||||
│ ├── architecture/
|
||||
│ │ ├── adr/
|
||||
│ │ ├── diagrams/
|
||||
│ │ └── domain-model.md
|
||||
│ ├── openapi/
|
||||
│ │ └── openapi.yaml
|
||||
│ └── website/ (Docusaurus — post-MVP)
|
||||
│ ├── epics/
|
||||
│ ├── features/
|
||||
│ ├── inbox.md
|
||||
│ └── roadmap.md
|
||||
├── apps/
|
||||
│ └── docs/
|
||||
│ └── docs/
|
||||
│ └── adr/
|
||||
├── package.json
|
||||
├── tsconfig.base.json
|
||||
└── .gitignore
|
||||
514
docs/features/pagination-loading-overlay/plan.md
Normal file
514
docs/features/pagination-loading-overlay/plan.md
Normal file
@ -0,0 +1,514 @@
|
||||
# Pagination Loading Overlay — 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:** Add overlay + spinner to all broker paginated tables when switching pages
|
||||
|
||||
**Architecture:** Use TanStack Query's `isFetching` (any fetch) vs `isLoading` (initial fetch) to show overlay when data exists and a new page is loading. Keep `keepPreviousData` so old data stays visible under the overlay. Add CSS spinner animation.
|
||||
|
||||
**Tech Stack:** React, TanStack Query v5, CSS custom properties
|
||||
|
||||
---
|
||||
|
||||
### Task 1: CSS — spinner animation and overlay styles
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/styles.css`
|
||||
|
||||
- [ ] **Step 1: Add spinner keyframes and loading-spinner class**
|
||||
|
||||
Add to `apps/frontend/src/styles.css` at the end:
|
||||
|
||||
```css
|
||||
@keyframes loading-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-bg);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: loading-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/styles.css
|
||||
git commit -m "style: add loading-spinner and overlay CSS classes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: PositionGroupTable — overlay on pagination + spinner in buttons
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `isFetching` to the query destructuring**
|
||||
|
||||
Line 103 changes from:
|
||||
```tsx
|
||||
const { data: page, isLoading } = useBrokerPositions(accountId, query);
|
||||
```
|
||||
to:
|
||||
```tsx
|
||||
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the loading rendering section**
|
||||
|
||||
Current (lines 179-213):
|
||||
```tsx
|
||||
{isLoading && (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>Тикер</th>
|
||||
<th align="left" style={thStyle}>Название</th>
|
||||
<th align="right" style={thStyle}>Количество</th>
|
||||
<th align="right" style={thStyle}>Цена</th>
|
||||
<th align="right" style={thStyle}>Стоимость</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableSkeleton rows={4} columns={[
|
||||
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
|
||||
]} />
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && positions.length > 0 && (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
||||
...
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Replace with new rendering logic:
|
||||
|
||||
```tsx
|
||||
{isLoading && (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>Тикер</th>
|
||||
<th align="left" style={thStyle}>Название</th>
|
||||
<th align="right" style={thStyle}>Количество</th>
|
||||
<th align="right" style={thStyle}>Цена</th>
|
||||
<th align="right" style={thStyle}>Стоимость</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableSkeleton rows={4} columns={[
|
||||
{ width: '30%' }, { width: '50%' }, { width: '20%' }, { width: '25%' }, { width: '25%' },
|
||||
]} />
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && positions.length > 0 && (
|
||||
<div className="table-container">
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>Тикер</th>
|
||||
<th align="left" style={thStyle}>Название</th>
|
||||
<th align="right" style={thStyle}>Количество</th>
|
||||
<th align="right" style={thStyle}>Цена</th>
|
||||
<th align="right" style={thStyle}>Стоимость</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((position) => (
|
||||
<tr
|
||||
key={
|
||||
position.positionUid ||
|
||||
position.instrumentUid ||
|
||||
position.ticker ||
|
||||
position.figi
|
||||
}
|
||||
>
|
||||
<td style={tdStyle}><PositionTicker position={position} /></td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{position.name || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td align="right" style={tdStyle}>{formatQuantity(position.quantity)}</td>
|
||||
<td align="right" style={tdStyle}>{formatMoney(position.currentPrice)}</td>
|
||||
<td align="right" style={tdStyle}>{formatMoney(position.currentValue)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className="table-loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
Загрузка страницы {pageNumber}…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update pagination buttons to show spinner during fetch**
|
||||
|
||||
Replace the button content in lines 167-174 (the "→" button):
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
|
||||
</button>
|
||||
```
|
||||
|
||||
Also update the "←" button (lines 148-155):
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx
|
||||
git commit -m "feat: add loading overlay and spinner to PositionGroupTable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: BrokerOperationsTable — new `isFetching` prop + overlay
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `isFetching` to props interface**
|
||||
|
||||
Change the component props destructuring (line 96-113):
|
||||
|
||||
```tsx
|
||||
export function BrokerOperationsTable({
|
||||
isLoading,
|
||||
isFetching,
|
||||
page,
|
||||
pageNumber,
|
||||
canGoBack,
|
||||
canGoForward,
|
||||
onPrevious,
|
||||
onNext,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
page: BrokerOperationsPage | undefined;
|
||||
pageNumber: number;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the loading/empty/data rendering**
|
||||
|
||||
Current (lines 158-229):
|
||||
|
||||
```tsx
|
||||
{isLoading ? (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>Дата</th>
|
||||
<th align="left" style={thStyle}>Тип</th>
|
||||
<th align="left" style={thStyle}>Инструмент</th>
|
||||
<th align="right" style={thStyle}>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableSkeleton
|
||||
rows={5}
|
||||
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
) : operations.length === 0 ? (
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>Дата</th>
|
||||
<th align="left" style={thStyle}>Тип</th>
|
||||
<th align="left" style={thStyle}>Инструмент</th>
|
||||
<th align="right" style={thStyle}>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{operations.map((operation) => {
|
||||
const impact = getBrokerOperationImpact(operation);
|
||||
return (
|
||||
<tr key={operation.cursor || operation.id}>
|
||||
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
||||
<td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
|
||||
<td style={tdStyle}><OperationInstrument operation={operation} /></td>
|
||||
<td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
|
||||
{formatMoney(operation.payment)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
{isLoading ? (
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>Дата</th>
|
||||
<th align="left" style={thStyle}>Тип</th>
|
||||
<th align="left" style={thStyle}>Инструмент</th>
|
||||
<th align="right" style={thStyle}>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TableSkeleton
|
||||
rows={5}
|
||||
columns={[{ width: '35%' }, { width: '30%' }, { width: '40%' }, { width: '25%' }]}
|
||||
/>
|
||||
</table>
|
||||
</div>
|
||||
) : operations.length === 0 && !isFetching ? (
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||
<table style={tableStyle}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style={thStyle}>Дата</th>
|
||||
<th align="left" style={thStyle}>Тип</th>
|
||||
<th align="left" style={thStyle}>Инструмент</th>
|
||||
<th align="right" style={thStyle}>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{operations.map((operation) => {
|
||||
const impact = getBrokerOperationImpact(operation);
|
||||
return (
|
||||
<tr key={operation.cursor || operation.id}>
|
||||
<td style={tdStyle}>{formatDate(operation.date)}</td>
|
||||
<td style={tdStyle}><span>{getBrokerOperationTypeLabel(operation)}</span></td>
|
||||
<td style={tdStyle}><OperationInstrument operation={operation} /></td>
|
||||
<td align="right" style={{ ...tdStyle, color: moneyColor(impact), fontWeight: 700 }}>
|
||||
{formatMoney(operation.payment)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className="table-loading-overlay">
|
||||
<div className="loading-spinner" />
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
Загрузка страницы {pageNumber}…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
Note: The empty state check changed from `operations.length === 0` to `operations.length === 0 && !isFetching` — this ensures the overlay shows on top of old data, not the empty message.
|
||||
|
||||
- [ ] **Step 3: Update pagination buttons**
|
||||
|
||||
Replace line 132-135 (← button):
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '←'}
|
||||
</button>
|
||||
```
|
||||
|
||||
Replace lines 147-154 (→ button):
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? <span className="loading-spinner" style={{ width: 14, height: 14, display: 'block' }} /> : '→'}
|
||||
</button>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx
|
||||
git commit -m "feat: add loading overlay and spinner to BrokerOperationsTable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: BrokerAccountDetailPage — pass `isFetching` to operations table
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `isFetching` to the BrokerOperationsTable props**
|
||||
|
||||
Change the `<BrokerOperationsTable>` call (line 123-131):
|
||||
|
||||
```tsx
|
||||
<BrokerOperationsTable
|
||||
isLoading={operations.isLoading}
|
||||
isFetching={operations.isFetching}
|
||||
page={operations.data}
|
||||
pageNumber={operationCursorStack.length + 1}
|
||||
canGoBack={operationCursorStack.length > 0}
|
||||
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
|
||||
onPrevious={handlePreviousOperationsPage}
|
||||
onNext={handleNextOperationsPage}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
|
||||
git commit -m "feat: pass isFetching to BrokerOperationsTable"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `isFetching: false` to all existing position mocks**
|
||||
|
||||
In `mockUseBrokerPositions` (line 47-61), add `isFetching: false`:
|
||||
|
||||
```tsx
|
||||
return {
|
||||
data: {
|
||||
accountId: 'acc-1',
|
||||
items: filtered,
|
||||
nextCursor: null,
|
||||
hasNext: false,
|
||||
asOf: '2026-06-17T00:00:00.000Z',
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `isFetching: false` to all operations mocks**
|
||||
|
||||
Add `isFetching: false` alongside each `isLoading: false` in the operations mocks (lines 58, 85, 112, 147, 190, 201, 266, 324, 363, 433).
|
||||
|
||||
For example, line 112 area becomes:
|
||||
```tsx
|
||||
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z' },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify tests pass**
|
||||
|
||||
```bash
|
||||
npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend
|
||||
```
|
||||
|
||||
Expected: All tests PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/pages/broker/BrokerPages.test.tsx
|
||||
git commit -m "test: add isFetching to mock return values"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Lint and final verification
|
||||
|
||||
- [ ] **Step 1: Run lint**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: No errors (or only pre-existing ones).
|
||||
|
||||
- [ ] **Step 2: Run full frontend test suite**
|
||||
|
||||
```bash
|
||||
npm run test:frontend
|
||||
```
|
||||
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
```bash
|
||||
npx tsc -b apps/frontend
|
||||
```
|
||||
|
||||
Expected: No type errors.
|
||||
261
docs/features/portfolio-allocation-chart/plan.md
Normal file
261
docs/features/portfolio-allocation-chart/plan.md
Normal file
@ -0,0 +1,261 @@
|
||||
# Диаграмма распределения портфеля — План реализации
|
||||
|
||||
> **Для агентов:** Требуется навык `superpowers:subagent-driven-development` или `superpowers:executing-plans`. Шаги используют `- [ ]`.
|
||||
|
||||
**Цель:** Добавить SVG-диаграмму donut в PortfolioSummary, показывающую распределение стоимости между акциями и облигациями.
|
||||
|
||||
**Архитектура:** Всё на клиенте. Бэкенд уже возвращает `positions` с `currentValue` и `type`. Новый компонент `AllocationChart` агрегирует данные и рисует SVG. `PortfolioSummary` включает его.
|
||||
|
||||
**Технологии:** React 18, SVG (без библиотек).
|
||||
|
||||
---
|
||||
|
||||
### Задача 1: Создать AllocationChart
|
||||
|
||||
**Файлы:**
|
||||
- Создать: `apps/frontend/src/components/portfolios/AllocationChart.tsx`
|
||||
|
||||
- [ ] **Шаг 1: Создать AllocationChart.tsx**
|
||||
|
||||
```tsx
|
||||
import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface AllocationChartProps {
|
||||
positions: PositionWithPrice[];
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
interface SectorData {
|
||||
type: 'share' | 'bond';
|
||||
label: string;
|
||||
value: number;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const SECTOR_COLORS = {
|
||||
share: 'var(--color-primary, #1976d2)',
|
||||
bond: '#f57c00',
|
||||
} as const;
|
||||
|
||||
const SECTOR_LABELS = {
|
||||
share: 'Акции',
|
||||
bond: 'Облигации',
|
||||
} as const;
|
||||
|
||||
function computeSectors(positions: PositionWithPrice[]): SectorData[] {
|
||||
const sectors: SectorData[] = [
|
||||
{ type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share },
|
||||
{ type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond },
|
||||
];
|
||||
|
||||
for (const p of positions) {
|
||||
const sector = sectors.find((s) => s.type === p.type);
|
||||
if (sector) {
|
||||
sector.value += p.currentValue ?? 0;
|
||||
sector.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return sectors;
|
||||
}
|
||||
|
||||
export function AllocationChart({ positions, totalValue }: AllocationChartProps) {
|
||||
const sectors = computeSectors(positions);
|
||||
const nonZero = sectors.filter((s) => s.value > 0);
|
||||
const hasData = nonZero.length > 0;
|
||||
|
||||
const cx = 60;
|
||||
const cy = 60;
|
||||
const r = 44;
|
||||
const strokeWidth = 10;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const viewBoxSize = 120;
|
||||
|
||||
function renderArcs() {
|
||||
if (!hasData) {
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth={strokeWidth}
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (nonZero.length === 1) {
|
||||
const sector = nonZero[0];
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={sector.color}
|
||||
strokeWidth={strokeWidth}
|
||||
transform={`rotate(-90 ${cx} ${cy})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return sectors.map((sector, i) => {
|
||||
const ratio = totalValue > 0 ? sector.value / totalValue : 0;
|
||||
const dashLen = ratio * circumference;
|
||||
const gapLen = circumference - dashLen;
|
||||
let rotation = -90;
|
||||
|
||||
for (let j = 0; j < i; j++) {
|
||||
const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0;
|
||||
rotation += prevRatio * 360;
|
||||
}
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={sector.type}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke={sector.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${dashLen} ${gapLen}`}
|
||||
transform={`rotate(${rotation} ${cx} ${cy})`}
|
||||
style={{ transition: 'stroke-dasharray 0.3s ease' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<svg
|
||||
width={90}
|
||||
height={90}
|
||||
viewBox={`0 0 ${viewBoxSize} ${viewBoxSize}`}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{renderArcs()}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={{
|
||||
fontSize: hasData && positions.length > 0 ? 14 : 10,
|
||||
fontWeight: 700,
|
||||
fill: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{positions.length === 0
|
||||
? 'Нет позиций'
|
||||
: totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })}
|
||||
</text>
|
||||
</svg>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.6 }}>
|
||||
{sectors.map((s) => {
|
||||
const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0;
|
||||
return (
|
||||
<div key={s.type} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 2,
|
||||
background: s.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{s.label}: {s.count} / {ratio.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Шаг 2: Проверить сборку**
|
||||
|
||||
Run: `npm run build:frontend`
|
||||
Expected: без ошибок
|
||||
|
||||
---
|
||||
|
||||
### Задача 2: Интегрировать в PortfolioSummary
|
||||
|
||||
**Файлы:**
|
||||
- Изменить: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx`
|
||||
|
||||
- [ ] **Шаг 1: Обновить PortfolioSummary**
|
||||
|
||||
```tsx
|
||||
import { AllocationChart } from './AllocationChart';
|
||||
import type { PortfolioDetail } from '../../api/responses';
|
||||
|
||||
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 32,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
}}
|
||||
>
|
||||
<AllocationChart positions={portfolio.positions} totalValue={portfolio.totalValue} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Общая стоимость
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{portfolio.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Позиций
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Шаг 2: Проверить сборку**
|
||||
|
||||
Run: `npm run build:frontend`
|
||||
Expected: без ошибок
|
||||
|
||||
- [ ] **Шаг 3: Проверить линтер**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: без ошибок
|
||||
|
||||
- [ ] **Шаг 4: Проверить форматирование**
|
||||
|
||||
Run: `npm run format`
|
||||
Expected: без изменений
|
||||
1037
docs/features/portfolio-analytics/plan.md
Normal file
1037
docs/features/portfolio-analytics/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -368,7 +368,7 @@ model Position {
|
||||
|
||||
## 10. OpenAPI Specification
|
||||
|
||||
Дополнения к существующему `docs/openapi/openapi.yaml`:
|
||||
Дополнения к текущему backend OpenAPI-контракту по `/api/docs-json`:
|
||||
|
||||
- Обновить схему `PositionResponse` — добавить `buyPrice`, `buyDate`
|
||||
- Создать схему `PortfolioAnalytics` со всеми полями
|
||||
365
docs/features/portfolio-enricher-optimization/plan.md
Normal file
365
docs/features/portfolio-enricher-optimization/plan.md
Normal file
@ -0,0 +1,365 @@
|
||||
# Portfolio Enricher Optimization — 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 (`- [ ]`) for tracking.
|
||||
|
||||
**Goal:** Reduce portfolio enrichment from 298 MOEX API calls (~30s) to 2 batch calls (~0.3s) by merging redundant bond data calls, eliminating extra security descriptions, and batching by market.
|
||||
|
||||
**Architecture:** 3-phase: (1) type changes, (2) new batch methods on MoexClientService, (3) rewrite PortfolioService.enrichPositions to use batch + remove redundant calls.
|
||||
|
||||
**Tech Stack:** NestJS, TypeScript, MOEX ISS API, PQueue
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add types — `shortName` on share market data + `MoexBondPositionData` combined type
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/moex-client/moex-client.types.ts`
|
||||
|
||||
- [ ] **Step 1: Extend `MoexShareMarketData` with `shortName`**
|
||||
|
||||
Add `shortName: string;` field — it's already returned by MOEX in the `securities` table of the share endpoint, but was never extracted.
|
||||
|
||||
- [ ] **Step 2: Add `MoexBondPositionData` combined type**
|
||||
|
||||
```typescript
|
||||
export interface MoexBondPositionData {
|
||||
secid: string;
|
||||
boardid: string;
|
||||
shortName: string;
|
||||
price: number | null;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
couponValue: number | null;
|
||||
couponPercent: number | null;
|
||||
nextCouponDate: string | null;
|
||||
matDate: string | null;
|
||||
accruedInt: number | null;
|
||||
faceValue: number;
|
||||
bid: number | null;
|
||||
offer: number | null;
|
||||
couponPeriod: number | null;
|
||||
bondType: string | null;
|
||||
offerDate: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
This replaces the need for both `MoexBondData` + `MoexBondMarketData` — combined from a single endpoint response.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add batch methods to MoexClientService
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/moex-client/moex-client.service.ts`
|
||||
|
||||
- [ ] **Step 1: Add `getShareMarketDataBatch` method**
|
||||
|
||||
```typescript
|
||||
async getShareMarketDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQBR',
|
||||
): Promise<MoexShareMarketData[]> {
|
||||
if (secids.length === 0) return [];
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const sec = securities.find((r) => r.SECID === secid && r.BOARDID === boardId)
|
||||
?? securities.find((r) => r.SECID === secid);
|
||||
const mkt = marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId)
|
||||
?? marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (sec?.SHORTNAME as string) || '',
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||||
last: mkt
|
||||
? parseFloat((mkt.LAST as string) || '')
|
||||
: parseFloat((sec?.PREVPRICE as string) || ''),
|
||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Key: uses existing `request()` method (rate-limited via PQueue). The `securities` param accepts comma-separated secids.
|
||||
|
||||
- [ ] **Step 2: Add `getBondPositionDataBatch` method**
|
||||
|
||||
```typescript
|
||||
async getBondPositionDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQCB',
|
||||
): Promise<MoexBondPositionData[]> {
|
||||
if (secids.length === 0) return [];
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities`,
|
||||
{ securities: secids.join(','), boards: boardId },
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
return secids.map((secid) => {
|
||||
const bond =
|
||||
securities.find((r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
||||
securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) ||
|
||||
securities.find((r) => r.SECID === secid);
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
||||
marketdata.find((r) => r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (bond?.SHORTNAME as string) || '',
|
||||
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||||
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||||
couponPercent: bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||||
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
|
||||
matDate: (bond?.MATDATE as string) || null,
|
||||
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||||
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
|
||||
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
|
||||
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||||
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
|
||||
bondType: (bond?.BONDTYPE as string) || null,
|
||||
offerDate: (bond?.OFFERDATE as string) || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
This replaces `getBondData` + `getBondMarketData` with a single batch call that parses both tables.
|
||||
|
||||
- [ ] **Step 3: Update `getShareMarketData` to also extract `shortName`**
|
||||
|
||||
In the single-security `getShareMarketData`, find the securities row and extract shortName:
|
||||
|
||||
```typescript
|
||||
const share = rows.find((r) => r.BOARDID === boardId);
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (share?.SHORTNAME as string) || '', // NEW
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
// ... rest unchanged
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run existing tests**
|
||||
|
||||
```bash
|
||||
npx vitest run -w apps/backend
|
||||
```
|
||||
|
||||
Expected: existing tests pass (no regressions).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Rewrite `enrichPositions` in PortfolioService
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts`
|
||||
|
||||
- [ ] **Step 1: Rewrite `enrichPositions` to use batch + eliminate redundant calls**
|
||||
|
||||
Strategy:
|
||||
1. Group positions by type (share/bond)
|
||||
2. For shares: 1 `getShareMarketDataBatch` call → map by secid
|
||||
3. For bonds: 1 `getBondPositionDataBatch` call → map by secid
|
||||
4. Build enriched positions from maps (no more individual API calls)
|
||||
5. shortName comes from market data response (no more `getSecurityDescription`)
|
||||
|
||||
```typescript
|
||||
private async enrichPositions(
|
||||
positions: {
|
||||
id: number; portfolioId: number; secid: string;
|
||||
type: string; quantity: number; notes: string | null; tags: string | null;
|
||||
}[],
|
||||
portfolioId: number,
|
||||
): Promise<EnrichedPosition[]> {
|
||||
const sharePositions = positions.filter((p) => p.type === 'share');
|
||||
const bondPositions = positions.filter((p) => p.type === 'bond');
|
||||
const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort();
|
||||
const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort();
|
||||
|
||||
const [shareDataBySecid, bondDataBySecid] = await Promise.all([
|
||||
this.fetchShareBatch(shareSecids, portfolioId),
|
||||
this.fetchBondBatch(bondSecids, portfolioId),
|
||||
]);
|
||||
|
||||
const enriched: EnrichedPosition[] = [];
|
||||
|
||||
for (const pos of positions) {
|
||||
const base = {
|
||||
id: pos.id, secid: pos.secid,
|
||||
shortName: null as string | null,
|
||||
type: pos.type, quantity: pos.quantity,
|
||||
notes: pos.notes, tags: pos.tags ? JSON.parse(pos.tags) : null,
|
||||
weightPercent: 0, currentPrice: null as number | null,
|
||||
currentValue: null as number | null,
|
||||
};
|
||||
|
||||
if (pos.type === 'bond') {
|
||||
enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid)));
|
||||
} else {
|
||||
enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid)));
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}
|
||||
|
||||
private async fetchShareBatch(
|
||||
secids: string[], portfolioId: number,
|
||||
): Promise<Map<string, MoexShareMarketData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = secids.join(',');
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata', ['shares', cacheKey],
|
||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
}
|
||||
|
||||
private async fetchBondBatch(
|
||||
secids: string[], portfolioId: number,
|
||||
): Promise<Map<string, MoexBondPositionData>> {
|
||||
if (secids.length === 0) return new Map();
|
||||
const cacheKey = secids.join(',');
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata', ['bonds', cacheKey],
|
||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `buildSharePosition` method**
|
||||
|
||||
```typescript
|
||||
private buildSharePosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
base: EnrichedPosition,
|
||||
data: MoexShareMarketData | undefined,
|
||||
): EnrichedPosition {
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice: data.last,
|
||||
change: data.lastChange,
|
||||
changePercent: data.lastChangePrcnt,
|
||||
currentValue: data.last !== null ? data.last * pos.quantity : null,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `buildBondPosition` method**
|
||||
|
||||
```typescript
|
||||
private buildBondPosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
base: EnrichedPosition,
|
||||
data: MoexBondPositionData | undefined,
|
||||
): EnrichedPosition {
|
||||
if (!data) return { ...base, currentPrice: null, currentValue: null };
|
||||
const currentValue =
|
||||
data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null;
|
||||
return {
|
||||
...base,
|
||||
shortName: data.shortName,
|
||||
currentPrice: data.price,
|
||||
yieldToMaturity: data.yieldToMaturity,
|
||||
duration: data.duration,
|
||||
couponValue: data.couponValue,
|
||||
couponPercent: data.couponPercent,
|
||||
nextCouponDate: data.nextCouponDate,
|
||||
matDate: data.matDate,
|
||||
accruedInt: data.accruedInt,
|
||||
bid: data.bid,
|
||||
offer: data.offer,
|
||||
couponPeriod: data.couponPeriod,
|
||||
bondType: data.bondType,
|
||||
offerDate: data.offerDate,
|
||||
currentValue,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `findOne` to pass `portfolio.id` to `enrichPositions`**
|
||||
|
||||
```typescript
|
||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions, portfolio.id);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Clean up removed methods**
|
||||
|
||||
Remove old private methods: `enrichSharePosition`, `enrichBondPosition` (replaced by `buildSharePosition`, `buildBondPosition`).
|
||||
|
||||
- [ ] **Step 6: Remove unused import `CacheService` if it becomes unused**
|
||||
|
||||
Actually `CacheService` is still used via `fetchShareBatch`/`fetchBondBatch`. Keep it.
|
||||
|
||||
- [ ] **Step 7: Run tests**
|
||||
|
||||
```bash
|
||||
npx vitest run -w apps/backend
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verify and lint
|
||||
|
||||
- [ ] **Step 1: TypeScript check**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit -w apps/backend
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lint**
|
||||
|
||||
```bash
|
||||
npm run lint 2>/dev/null || echo "Lint check complete"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Format**
|
||||
|
||||
```bash
|
||||
npm run format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Document performance gain
|
||||
|
||||
- [ ] **Step 1: Write ADR or performance note in docs**
|
||||
|
||||
Add to `apps/docs/docs/adr/` as a new ADR documenting:
|
||||
- Problem: 298 API calls → 29s
|
||||
- Changes made: merged bond calls, removed redundant securityDescription, batch by market
|
||||
- Result: 2 API calls → ~0.3s (97% reduction)
|
||||
613
docs/features/portfolio-list-enrichment/plan.md
Normal file
613
docs/features/portfolio-list-enrichment/plan.md
Normal file
@ -0,0 +1,613 @@
|
||||
# Portfolio List Enrichment — 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:** Enrich `GET /api/v1/portfolios` with totalValue, positionCount, shareCount, bondCount from MOEX batch data and display on PortfolioCard.
|
||||
|
||||
**Architecture:** Backend collects all positions across user's portfolios, does ONE batch MOEX call (cached), computes aggregates per portfolio. Frontend displays new fields on the existing card component.
|
||||
|
||||
**Tech Stack:** NestJS, Prisma, MoexClientService (batch), React, TanStack Query
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create PortfolioListResponseDto
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts`
|
||||
|
||||
- [ ] **Step 1: Create DTO file**
|
||||
|
||||
```typescript
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { PortfolioResponseDto } from './portfolio-response.dto';
|
||||
|
||||
export class PortfolioListResponseDto extends PortfolioResponseDto {
|
||||
@ApiProperty({ description: 'Total market value of all positions' })
|
||||
totalValue!: number;
|
||||
|
||||
@ApiProperty({ description: 'Total number of positions' })
|
||||
positionCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of share positions' })
|
||||
shareCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Number of bond positions' })
|
||||
bondCount!: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc --noEmit -w apps/backend`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts
|
||||
git commit -m "feat(backend): add PortfolioListResponseDto"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Write failing tests for PortfolioService.findAll enrichment
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Create test file with failing tests**
|
||||
|
||||
```typescript
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
let service: PortfolioService;
|
||||
let prisma: PrismaService;
|
||||
let moexClient: MoexClientService;
|
||||
let module: TestingModule;
|
||||
|
||||
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
userId: 1,
|
||||
name: 'Test Portfolio',
|
||||
description: 'A test portfolio',
|
||||
currency: 'RUB',
|
||||
targets: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-06-14'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockPosition = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
portfolioId: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
notes: null,
|
||||
tags: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-06-14'),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
module = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
PortfolioService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: {
|
||||
portfolio: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
position: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MoexClientService,
|
||||
useValue: {
|
||||
getShareMarketDataBatch: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
getSecurityDescription: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: {
|
||||
getOrFetch: vi.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PortfolioService>(PortfolioService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
||||
// CacheService is a useValue mock object
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return empty array when user has no portfolios', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
|
||||
const result = await service.findAll(1);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return portfolios with zero aggregates when no positions exist', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
mockPortfolio({ positions: [] }) as any,
|
||||
]);
|
||||
|
||||
const result = await service.findAll(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: 'Test Portfolio',
|
||||
totalValue: 0,
|
||||
positionCount: 0,
|
||||
shareCount: 0,
|
||||
bondCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should enrich portfolios with market data from batch MOEX call', async () => {
|
||||
const sharePosition = mockPosition({
|
||||
id: 1,
|
||||
secid: 'SBER',
|
||||
type: 'share',
|
||||
quantity: 10,
|
||||
});
|
||||
const bondPosition = mockPosition({
|
||||
id: 2,
|
||||
portfolioId: 1,
|
||||
secid: 'SU26238RMFS5',
|
||||
type: 'bond',
|
||||
quantity: 5,
|
||||
});
|
||||
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
||||
]);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||
] as any);
|
||||
|
||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'OFZ 26238',
|
||||
price: 98.5,
|
||||
faceValue: 1000,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockImplementation(
|
||||
async (_prefix: string, _key: string[], fetchFn: () => Promise<any>) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.findAll(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('Test Portfolio');
|
||||
expect(result[0].positionCount).toBe(2);
|
||||
expect(result[0].shareCount).toBe(1);
|
||||
expect(result[0].bondCount).toBe(1);
|
||||
// SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925
|
||||
expect(result[0].totalValue).toBe(7425);
|
||||
});
|
||||
|
||||
it('should propagate MOEX errors to the caller', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([
|
||||
mockPortfolio({ positions: [mockPosition()] }) as any,
|
||||
]);
|
||||
|
||||
const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType<typeof vi.fn> };
|
||||
cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down'));
|
||||
|
||||
await expect(service.findAll(1)).rejects.toThrow('MOEX down');
|
||||
});
|
||||
|
||||
it('should only return portfolios belonging to the requesting user', async () => {
|
||||
vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]);
|
||||
|
||||
await service.findAll(2);
|
||||
|
||||
expect(prisma.portfolio.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 2 },
|
||||
include: { positions: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should throw NotFoundException for non-existent portfolio', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null);
|
||||
await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for wrong user', async () => {
|
||||
vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any);
|
||||
await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||
Expected: FAIL — tests assert behavior that's not yet implemented
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement backend enrichment in PortfolioService.findAll
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` — rewrite `findAll` method
|
||||
|
||||
- [ ] **Step 1: Replace the findAll method**
|
||||
|
||||
Current code (lines 62-67):
|
||||
```typescript
|
||||
async findAll(userId: number) {
|
||||
return this.prisma.portfolio.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
async findAll(userId: number) {
|
||||
const portfolios = await this.prisma.portfolio.findMany({
|
||||
where: { userId },
|
||||
include: { positions: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const allPositions = portfolios.flatMap((p) => p.positions);
|
||||
if (allPositions.length === 0) {
|
||||
return portfolios.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
currency: p.currency,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
totalValue: 0,
|
||||
positionCount: 0,
|
||||
shareCount: 0,
|
||||
bondCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const enrichedPositions = await this.enrichPositions(allPositions);
|
||||
|
||||
const posByPortfolioId = new Map<number, (typeof enrichedPositions)[number][]>();
|
||||
for (let i = 0; i < enrichedPositions.length; i++) {
|
||||
const pfId = allPositions[i].portfolioId;
|
||||
if (!posByPortfolioId.has(pfId)) {
|
||||
posByPortfolioId.set(pfId, []);
|
||||
}
|
||||
posByPortfolioId.get(pfId)!.push(enrichedPositions[i]);
|
||||
}
|
||||
|
||||
return portfolios.map((p) => {
|
||||
const positions = posByPortfolioId.get(p.id) ?? [];
|
||||
const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0);
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
currency: p.currency,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
totalValue: Math.round(totalValue * 100) / 100,
|
||||
positionCount: positions.length,
|
||||
shareCount: positions.filter((pos) => pos.type === 'share').length,
|
||||
bondCount: positions.filter((pos) => pos.type === 'bond').length,
|
||||
};
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they pass**
|
||||
|
||||
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/portfolio/portfolio.service.ts \
|
||||
apps/backend/src/modules/portfolio/portfolio.service.spec.ts
|
||||
git commit -m "feat(backend): enrich portfolio list with MOEX batch data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update PortfolioController with new DTO
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/backend/src/modules/portfolio/portfolio.controller.ts`
|
||||
|
||||
- [ ] **Step 1: Import PortfolioListResponseDto**
|
||||
|
||||
Add import at top:
|
||||
```typescript
|
||||
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update findAll to use new DTO in Swagger**
|
||||
|
||||
Replace method with ApiResponse decorator:
|
||||
```typescript
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
|
||||
async findAll(@CurrentUser() user: { sub: number }) {
|
||||
const portfolios = await this.portfolioService.findAll(user.sub);
|
||||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
```
|
||||
|
||||
Also add the import:
|
||||
```typescript
|
||||
import { ApiOkResponse } from '@nestjs/swagger';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing tests to verify no regressions**
|
||||
|
||||
Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/backend/src/modules/portfolio/portfolio.controller.ts
|
||||
git commit -m "feat(backend): add Swagger decorators for enriched portfolio list"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update frontend types
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/api/responses.ts`
|
||||
|
||||
- [ ] **Step 1: Add new fields to Portfolio interface**
|
||||
|
||||
Current (lines 138-145):
|
||||
```typescript
|
||||
export interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
export interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
totalValue: number;
|
||||
positionCount: number;
|
||||
shareCount: number;
|
||||
bondCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify TypeScript compiles**
|
||||
|
||||
Run: `npx tsc -b apps/frontend`
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/api/responses.ts
|
||||
git commit -m "feat(frontend): add enrichment fields to Portfolio type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update PortfolioCard to show enriched data
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/src/components/portfolios/PortfolioCard.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace PortfolioCard implementation**
|
||||
|
||||
Current (lines 1-39):
|
||||
```typescript
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Portfolio } from '../../api/responses';
|
||||
|
||||
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/portfolios/${portfolio.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3>
|
||||
{portfolio.description && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
{portfolio.description}
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginTop: 8,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
```typescript
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Portfolio } from '../../api/responses';
|
||||
|
||||
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
const chipStyle = (bg: string): React.CSSProperties => ({
|
||||
background: bg,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: '#fff',
|
||||
fontWeight: 500,
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/portfolios/${portfolio.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
||||
{portfolio.currency}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{portfolio.description && (
|
||||
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
{portfolio.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
{portfolio.shareCount > 0 && (
|
||||
<span style={chipStyle('#1b5e20')}>
|
||||
{portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')}
|
||||
</span>
|
||||
)}
|
||||
{portfolio.bondCount > 0 && (
|
||||
<span style={chipStyle('#0d47a1')}>
|
||||
{portfolio.bondCount} {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')}
|
||||
</span>
|
||||
)}
|
||||
<span style={chipStyle('#424242')}>
|
||||
{portfolio.positionCount} {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function pluralize(n: number, one: string, few: string, many: string): string {
|
||||
if (n % 10 === 1 && n % 100 !== 11) return one;
|
||||
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few;
|
||||
return many;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify frontend builds**
|
||||
|
||||
Run: `npm run build:frontend -w apps/frontend` (or `npx tsc -b apps/frontend`)
|
||||
Expected: No errors
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/src/components/portfolios/PortfolioCard.tsx
|
||||
git commit -m "feat(frontend): display enriched data in PortfolioCard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Run full test suite and verify
|
||||
|
||||
- [ ] **Step 1: Run backend tests**
|
||||
|
||||
Run: `npm run test:backend`
|
||||
Expected: All tests pass (including new portfolio service tests)
|
||||
|
||||
- [ ] **Step 2: Run frontend build**
|
||||
|
||||
Run: `npm run build:frontend`
|
||||
Expected: Build succeeds
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
Run: `npm run lint`
|
||||
Expected: No lint errors
|
||||
1615
docs/features/portfolio/plan.md
Normal file
1615
docs/features/portfolio/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -392,7 +392,7 @@ Phase 1 не требует событийной шины. События док
|
||||
|
||||
## 10. OpenAPI Specification
|
||||
|
||||
Дополнение к существующему `docs/openapi/openapi.yaml` — новые эндпоинты и схемы для Portfolio и Position.
|
||||
Дополнение к текущему backend OpenAPI-контракту по `/api/docs-json` — новые эндпоинты и схемы для Portfolio и Position.
|
||||
|
||||
---
|
||||
|
||||
187
docs/features/pre-commit-checks/plan.md
Normal file
187
docs/features/pre-commit-checks/plan.md
Normal file
@ -0,0 +1,187 @@
|
||||
# Pre-commit Checks 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:** Добавить pre-commit хуки (ESLint + Prettier) для backend и frontend, блокирующие коммит при ошибках.
|
||||
|
||||
**Architecture:** Husky + lint-staged в корне монорепозитория. ESLint 8 (совместим с существующим backend) для обеих workspace. Frontend получает свой `.eslintrc.cjs` с React-правилами.
|
||||
|
||||
**Tech Stack:** Husky 9, lint-staged 15, ESLint 8, Prettier 3
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ESLint для фронтенда
|
||||
|
||||
**Файлы:**
|
||||
- Создать: `apps/frontend/.eslintrc.cjs`
|
||||
- Изменить: `apps/frontend/package.json` (scripts + devDependencies)
|
||||
|
||||
- [ ] **Step 1: Добавить devDependencies в `apps/frontend/package.json`**
|
||||
|
||||
В секцию `devDependencies` добавить:
|
||||
|
||||
```json
|
||||
"eslint": "^8.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Добавить скрипт lint в `apps/frontend/package.json`**
|
||||
|
||||
```json
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Создать `apps/frontend/.eslintrc.cjs`**
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Проверить, что ESLint работает на фронтенде**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm run lint -w apps/frontend
|
||||
```
|
||||
|
||||
Expected: ESLint проверяет все `.ts,.tsx` файлы в `apps/frontend/src/`. Если есть ошибки — мы их фиксим. Если ошибок нет — чистый выход.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend/package.json apps/frontend/.eslintrc.cjs
|
||||
git commit -m "feat: add ESLint config for frontend"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Husky + lint-staged
|
||||
|
||||
**Файлы:**
|
||||
- Изменить: `package.json` (корень) — devDependencies + lint-staged config
|
||||
- Создать: `.husky/pre-commit`
|
||||
- Создать: `.husky/_/` (содержимое от `husky init`)
|
||||
|
||||
- [ ] **Step 1: Установить husky и lint-staged в корень**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm install --save-dev husky lint-staged
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Инициализировать Husky**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npx husky init
|
||||
```
|
||||
|
||||
Это создаст `.husky/` директорию с `pre-commit` хуком.
|
||||
|
||||
- [ ] **Step 3: Добавить lint-staged config в корневой `package.json`**
|
||||
|
||||
В корневой `package.json` добавить (после `devDependencies`):
|
||||
|
||||
```json
|
||||
"lint-staged": {
|
||||
"apps/backend/src/**/*.ts": ["eslint --max-warnings=0"],
|
||||
"apps/backend/test/**/*.ts": ["eslint --max-warnings=0"],
|
||||
"apps/frontend/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"],
|
||||
"*.{ts,tsx}": ["prettier --check"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Настроить `.husky/pre-commit`**
|
||||
|
||||
Проверить содержимое `.husky/pre-commit`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
```
|
||||
|
||||
Если `husky init` уже создал подходящий файл, оставить как есть. Убедиться, что вызов `npx lint-staged` присутствует.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json .husky/
|
||||
git commit -m "feat: add Husky pre-commit hook with lint-staged"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Обновить root lint script
|
||||
|
||||
**Файлы:**
|
||||
- Изменить: `package.json` (корень) — секция scripts
|
||||
|
||||
- [ ] **Step 1: Обновить `lint` скрипт в корневом `package.json`**
|
||||
|
||||
Найти строку:
|
||||
```json
|
||||
"lint": "npm run lint -w apps/backend",
|
||||
```
|
||||
|
||||
Заменить на:
|
||||
```json
|
||||
"lint": "npm run lint -w apps/backend && npm run lint -w apps/frontend",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Проверить, что корневой lint работает**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Expected: ESLint проходит по backend и frontend, возвращает 0 при отсутствии ошибок.
|
||||
|
||||
- [ ] **Step 3: Проверить chain целиком (опционально)**
|
||||
|
||||
Протестировать pre-commit hook:
|
||||
```bash
|
||||
git add . && git commit -m "test pre-commit hook"
|
||||
```
|
||||
|
||||
Должен выполнить lint-staged, проверить ESLint + Prettier на staged файлах.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "chore: update root lint script to cover both workspaces"
|
||||
```
|
||||
1390
docs/features/quality-gate-contract-docs/plan.md
Normal file
1390
docs/features/quality-gate-contract-docs/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,7 +17,7 @@
|
||||
настоящий `MoexClientService`.
|
||||
- Ошибки live MOEX-запросов проявляются как Vitest `DataCloneError`, потому что `AxiosError`
|
||||
содержит функции в конфигурации запроса, которые нельзя клонировать между worker'ами.
|
||||
- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` не содержат актуальные эндпоинты
|
||||
- backend Swagger JSON по `/api/docs-json` и `apps/frontend/src/api/types.ts` не отражают актуальные эндпоинты
|
||||
`auth`, `portfolios` и `securities/screener`.
|
||||
- README, AGENTS и страницы Docusaurus местами описывают старое состояние репозитория.
|
||||
- `npm run build:docs` успешно генерирует статические файлы, но выводит предупреждения Docusaurus о
|
||||
@ -78,7 +78,7 @@
|
||||
- `apps/docs/docs/backend/api.md` не содержит portfolio и screener endpoints.
|
||||
- `apps/docs/docs/backend/portfolio.md` документирует `PATCH /api/v1/portfolios/:id/patch`, хотя
|
||||
controller реализует `PATCH /api/v1/portfolios/:id`.
|
||||
- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` содержат только ранние paths для
|
||||
- backend Swagger JSON по `/api/docs-json` и `apps/frontend/src/api/types.ts` содержат только ранние paths для
|
||||
health, search, shares, bonds и candles.
|
||||
|
||||
## Доменная модель
|
||||
@ -110,7 +110,7 @@ decorators по `/api/docs-json`.
|
||||
|
||||
Сгенерированные или синхронизированные артефакты:
|
||||
|
||||
- `docs/openapi/openapi.yaml`: checked-in человекочитаемый snapshot.
|
||||
- `/api/docs-json`: live Swagger JSON, канонический machine-readable contract.
|
||||
- `apps/frontend/src/api/types.ts`: сгенерированные TypeScript path и schema types.
|
||||
- Docusaurus API pages: поясняющая документация, но не канонический machine contract.
|
||||
|
||||
@ -276,7 +276,7 @@ broken links на `/`. Отдельное update-check warning про permission
|
||||
|
||||
1. Добавить или завершить Swagger metadata для актуальных routes.
|
||||
2. Перегенерировать `apps/frontend/src/api/types.ts`.
|
||||
3. Синхронизировать `docs/openapi/openapi.yaml` с текущим contract.
|
||||
3. Проверить `/api/docs-json` и синхронизировать `apps/frontend/src/api/types.ts` с текущим contract.
|
||||
4. Проверить, что generated paths включают auth, screener и portfolio routes.
|
||||
|
||||
### Этап 3: обновить документацию
|
||||
@ -311,7 +311,7 @@ npm run format:check
|
||||
`/`.
|
||||
- `npm run format:check` завершается с exit code 0.
|
||||
- `apps/frontend/src/api/types.ts` содержит актуальные auth, screener и portfolio paths.
|
||||
- `docs/openapi/openapi.yaml` содержит актуальные auth, screener и portfolio paths.
|
||||
- `/api/docs-json` содержит актуальные auth, screener и portfolio paths.
|
||||
- README, AGENTS и Docusaurus docs больше не утверждают, что frontend tests отсутствуют.
|
||||
- Portfolio docs используют `PATCH /api/v1/portfolios/:id`, что соответствует controller.
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
## Контекст
|
||||
|
||||
Документация проекта публикуется только из `apps/docs` через Docusaurus. Текущая структура уже
|
||||
зафиксирована в `docs/superpowers/specs/2026-06-13-docusaurus-docs-design.md`: страницы лежат в
|
||||
`apps/docs/docs`, навигация описана в `apps/docs/sidebars.ts`, Mermaid включён через
|
||||
зафиксирована в `docs/features/docusaurus-docs/spec.md`: страницы лежат в `apps/docs/docs`,
|
||||
навигация описана в `apps/docs/sidebars.ts`, Mermaid включён через
|
||||
`@docusaurus/theme-mermaid`.
|
||||
|
||||
Сейчас большая часть человекочитаемого текста в опубликованной документации написана на английском:
|
||||
1614
docs/features/security-screener/plan.md
Normal file
1614
docs/features/security-screener/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -455,7 +455,7 @@ Phase 1 не требует событий. Для будущих фаз:
|
||||
|
||||
## 10. OpenAPI Specification
|
||||
|
||||
Дополнение к существующему `docs/openapi/openapi.yaml`:
|
||||
Дополнение к текущему backend OpenAPI-контракту по `/api/docs-json`:
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
15
docs/inbox.md
Normal file
15
docs/inbox.md
Normal file
@ -0,0 +1,15 @@
|
||||
# Inbox
|
||||
|
||||
## Ideas
|
||||
|
||||
- Добавить календарь дивидендов
|
||||
- Добавить экспорт портфеля в Excel
|
||||
- Добавить темную тему
|
||||
- Показывать прогноз дивидендов
|
||||
|
||||
## Improvements
|
||||
- Перфоманс api
|
||||
- Улучшить поиск по тикеру
|
||||
|
||||
## Questions
|
||||
- Нужна ли поддержка вкладов?
|
||||
0
docs/roadmap.md
Normal file
0
docs/roadmap.md
Normal file
Loading…
x
Reference in New Issue
Block a user