From 4c15bda30e4db7efbfbe28f4c3e6e025b0d0b4b5 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 13:02:24 +0300 Subject: [PATCH] feat: add allocation donut chart to portfolio summary --- .../components/portfolios/AllocationChart.tsx | 162 +++++++++++ .../portfolios/PortfolioSummary.tsx | 2 + .../2026-06-14-portfolio-allocation-chart.md | 261 ++++++++++++++++++ ...06-14-portfolio-allocation-chart-design.md | 54 ++++ 4 files changed, 479 insertions(+) create mode 100644 apps/frontend/src/components/portfolios/AllocationChart.tsx create mode 100644 docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md create mode 100644 docs/superpowers/specs/2026-06-14-portfolio-allocation-chart-design.md diff --git a/apps/frontend/src/components/portfolios/AllocationChart.tsx b/apps/frontend/src/components/portfolios/AllocationChart.tsx new file mode 100644 index 0000000..1a22c4f --- /dev/null +++ b/apps/frontend/src/components/portfolios/AllocationChart.tsx @@ -0,0 +1,162 @@ +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 ( + + ); + } + + if (nonZero.length === 1) { + const sector = nonZero[0]; + return ( + + ); + } + + 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 ( + + ); + }); + } + + return ( +
+ + {renderArcs()} + 0 ? 14 : 10, + fontWeight: 700, + fill: 'var(--color-text)', + }} + > + {positions.length === 0 + ? 'Нет позиций' + : totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })} + + +
+ {sectors.map((s) => { + const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0; + return ( +
+ + + {s.label}: {s.count} / {ratio.toFixed(1)}% + +
+ ); + })} +
+
+ ); +} diff --git a/apps/frontend/src/components/portfolios/PortfolioSummary.tsx b/apps/frontend/src/components/portfolios/PortfolioSummary.tsx index 937fed3..0e376b7 100644 --- a/apps/frontend/src/components/portfolios/PortfolioSummary.tsx +++ b/apps/frontend/src/components/portfolios/PortfolioSummary.tsx @@ -1,3 +1,4 @@ +import { AllocationChart } from './AllocationChart'; import type { PortfolioDetail } from '../../api/responses'; export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) { @@ -12,6 +13,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) borderRadius: 'var(--border-radius)', }} > +
Общая стоимость diff --git a/docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md b/docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md new file mode 100644 index 0000000..e1a318e --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md @@ -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 ( + + ); + } + + if (nonZero.length === 1) { + const sector = nonZero[0]; + return ( + + ); + } + + 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 ( + + ); + }); + } + + return ( +
+ + {renderArcs()} + 0 ? 14 : 10, + fontWeight: 700, + fill: 'var(--color-text)', + }} + > + {positions.length === 0 + ? 'Нет позиций' + : totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })} + + +
+ {sectors.map((s) => { + const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0; + return ( +
+ + + {s.label}: {s.count} / {ratio.toFixed(1)}% + +
+ ); + })} +
+
+ ); +} +``` + +- [ ] **Шаг 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 ( +
+ +
+
+ Общая стоимость +
+
+ {portfolio.totalValue.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {portfolio.currency} + +
+
+
+
+ Позиций +
+
{portfolio.positions.length}
+
+
+ ); +} +``` + +- [ ] **Шаг 2: Проверить сборку** + +Run: `npm run build:frontend` +Expected: без ошибок + +- [ ] **Шаг 3: Проверить линтер** + +Run: `npm run lint` +Expected: без ошибок + +- [ ] **Шаг 4: Проверить форматирование** + +Run: `npm run format` +Expected: без изменений diff --git a/docs/superpowers/specs/2026-06-14-portfolio-allocation-chart-design.md b/docs/superpowers/specs/2026-06-14-portfolio-allocation-chart-design.md new file mode 100644 index 0000000..8ca9da8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-portfolio-allocation-chart-design.md @@ -0,0 +1,54 @@ +# Диаграмма распределения портфеля — Спецификация + +## Обзор +Добавить аналитическую диаграмму (donut) на страницу детального просмотра портфеля, показывающую распределение стоимости между акциями и облигациями, с количеством позиций в легенде. + +## Источник данных +`PortfolioDetail.positions[]` — бэкенд уже возвращает: +- `type: 'share' | 'bond'` +- `currentValue: number | null` +- `quantity: number` + +Агрегация на клиенте. Изменений бэкенда не требуется. + +## Компонент: `AllocationChart` +- Чистый SVG (два сектора + текст в центре) +- Без внешних зависимостей +- Размещается внутри `PortfolioSummary` первым ребенком flex-ряда +- В центре — общая стоимость портфеля (или «Нет позиций») +- Справа — легенда с разбивкой по типу: количество / процент + +### Цветовая схема +| Сектор | Цвет | Легенда | +|--------|------|---------| +| Акции | `var(--color-primary)` / #1976d2 | `Акции: N / XX.X%` | +| Облигации | #f57c00 | `Облигации: N / XX.X%` | + +### Состояния +- **Обычное (оба типа):** две дуги пропорционально стоимости, в центре — сумма +- **Один тип (только акции или только облигации):** полный круг цветом сектора +- **Пусто (нет позиций):** серый круг, в центре «Нет позиций» + +### Расположение +``` +┌────────────────────────────────────────────────────────┐ +│ [⭕ donut] Общая стоимость Позиций │ +│ [xxx,xxx] xxx,xxx.xx RUB 12 │ +│ Акции: 8 / 70.5% │ +│ Облигации: 4 / 29.5% │ +└────────────────────────────────────────────────────────┘ +``` + +## Изменяемые файлы + +### Новый +- `apps/frontend/src/components/portfolios/AllocationChart.tsx` + +### Изменяемый +- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` — импорт и рендер `AllocationChart` + +## Граничные случаи + +- `currentValue === null` — считается как 0 +- У всех позиций `currentValue === 0` — серый круг, в центре «0» +- Один из типов отсутствует (0 позиций) — в легенде `0 / 0.0%`, дуга заполняет 100% для непустого типа