From af0aaeda9d82a57dfed86a184a5884302ceac2dc Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 27 Jun 2026 12:46:39 +0300 Subject: [PATCH] feat(frontend): add dashboard visual helpers for HTML parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce dashboardVisual.ts with moneyTone, formatDashboardCurrency, eventTypeTone, incomeTypeTone, and instrumentDisplay helpers used by the broker dashboard to match the HTML reference prototype. Extend dashboardIncome.ts: typeLabel now distinguishes DIV_EXT ('Дивиденд (внешний)'), and rows expose instrumentMain/instrumentSubtitle via instrumentDisplay while keeping instrument as a derived alias for existing callers. Add resetDashboardFilters factory in dashboardFilters.ts so Task 3 can wire the reset action without re-churning filter types. --- .../broker-dashboard/lib/dashboardFilters.ts | 6 + .../lib/dashboardIncome.test.ts | 33 ++++ .../broker-dashboard/lib/dashboardIncome.ts | 43 +++-- .../lib/dashboardVisual.test.ts | 151 ++++++++++++++++++ .../broker-dashboard/lib/dashboardVisual.ts | 71 ++++++++ 5 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts create mode 100644 apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts index 39abe1e..f5ba43a 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardFilters.ts @@ -69,3 +69,9 @@ export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): strin if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON') return [...operationTypes].join(',') } + +export function resetDashboardFilters( + factory: () => DashboardFilterState, +): DashboardFilterState { + return factory() +} diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts index 7c33f73..53e3a24 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.test.ts @@ -56,6 +56,39 @@ describe('dashboardIncome', () => { expect(rows[1].amount.value).toBe(5) }) + it('marks OPERATION_TYPE_DIV_EXT as "Дивиденд (внешний)"', () => { + const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIV_EXT', 12)]) + + expect(rows).toHaveLength(1) + expect(rows[0].typeLabel).toBe('Дивиденд (внешний)') + expect(rows[0].amount.value).toBe(12) + }) + + it('splits instrument into main and subtitle, keeping the legacy alias', () => { + const rows = getDashboardIncomeRows([operation('OPERATION_TYPE_DIVIDEND', 10)]) + + expect(rows[0].instrumentMain).toBe('AAPL') + expect(rows[0].instrumentSubtitle).toBe('Apple Inc.') + expect(rows[0].instrument).toBe('AAPL') + }) + + it('falls back to "—" with no subtitle when ticker, name, and description are missing', () => { + const base = operation('OPERATION_TYPE_COUPON', 7) + const rows = getDashboardIncomeRows([{ ...base, ticker: null, name: null, description: null }]) + + expect(rows[0].instrumentMain).toBe('—') + expect(rows[0].instrumentSubtitle).toBeNull() + expect(rows[0].instrument).toBe('—') + }) + + it('does not duplicate subtitle when name equals ticker', () => { + const base = operation('OPERATION_TYPE_COUPON', 3) + const rows = getDashboardIncomeRows([{ ...base, ticker: 'AAPL', name: 'AAPL' }]) + + expect(rows[0].instrumentMain).toBe('AAPL') + expect(rows[0].instrumentSubtitle).toBeNull() + }) + it('sums displayed income rows by currency', () => { const rows = getDashboardIncomeRows([ operation('OPERATION_TYPE_DIVIDEND', 10), diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts index bdd8485..8d58e10 100644 --- a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardIncome.ts @@ -1,4 +1,5 @@ import type { BrokerMoney, BrokerOperation } from '@/shared/api' +import { instrumentDisplay } from './dashboardVisual' const INCOME_TYPES = new Set([ 'OPERATION_TYPE_DIVIDEND', @@ -6,11 +7,15 @@ const INCOME_TYPES = new Set([ 'OPERATION_TYPE_COUPON', ]) +export type DashboardIncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон' + export type DashboardIncomeRow = { id: string date: string | null + instrumentMain: string + instrumentSubtitle: string | null instrument: string - typeLabel: 'Дивиденд' | 'Купон' + typeLabel: DashboardIncomeTypeLabel amount: BrokerMoney } @@ -18,25 +23,29 @@ export function isDashboardIncomeOperation(operation: BrokerOperation): boolean return INCOME_TYPES.has(operation.type) && operation.payment !== null } -function typeLabel(type: string): DashboardIncomeRow['typeLabel'] { - return type === 'OPERATION_TYPE_COUPON' ? 'Купон' : 'Дивиденд' +function typeLabel(type: string): DashboardIncomeTypeLabel { + if (type === 'OPERATION_TYPE_COUPON') return 'Купон' + if (type === 'OPERATION_TYPE_DIV_EXT') return 'Дивиденд (внешний)' + return 'Дивиденд' } export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] { - return operations.filter(isDashboardIncomeOperation).map((operation) => ({ - id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`), - date: typeof operation.date === 'string' ? operation.date : null, - instrument: - typeof operation.ticker === 'string' - ? operation.ticker - : typeof operation.name === 'string' - ? operation.name - : typeof operation.description === 'string' - ? operation.description - : '—', - typeLabel: typeLabel(operation.type), - amount: operation.payment!, - })) + return operations.filter(isDashboardIncomeOperation).map((operation) => { + const { main, subtitle } = instrumentDisplay({ + ticker: operation.ticker, + name: operation.name, + description: operation.description, + }) + return { + id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`), + date: typeof operation.date === 'string' ? operation.date : null, + instrumentMain: main, + instrumentSubtitle: subtitle, + instrument: main, + typeLabel: typeLabel(operation.type), + amount: operation.payment!, + } + }) } export function sumDashboardIncome( diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts new file mode 100644 index 0000000..a37421d --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'vitest' +import type { BrokerMoney } from '@/shared/api' +import { + type DashboardMoneyLike, + eventTypeTone, + formatDashboardCurrency, + incomeTypeTone, + instrumentDisplay, + moneyTone, +} from './dashboardVisual' + +function money(currency: string, value: number): BrokerMoney { + return { currency, units: String(Math.trunc(value)), nano: 0, value } +} + +describe('formatDashboardCurrency', () => { + it('renders RUB with the ₽ symbol', () => { + const result = formatDashboardCurrency(money('RUB', 1234.56)) + expect(result).toContain('₽') + expect(result).not.toContain('RUB') + }) + + it('falls back to currency code for unknown currencies', () => { + const result = formatDashboardCurrency(money('USD', 42)) + expect(result).toContain('USD') + expect(result).not.toContain('₽') + }) + + it('falls back to currency code via the value-only shape', () => { + const result = formatDashboardCurrency({ currency: 'EUR', value: 9.99 } as DashboardMoneyLike) + expect(result).toContain('EUR') + }) + + it('returns "—" for null or undefined', () => { + expect(formatDashboardCurrency(null)).toBe('—') + expect(formatDashboardCurrency(undefined)).toBe('—') + }) +}) + +describe('moneyTone', () => { + it('returns positive for positive actual values', () => { + expect(moneyTone(10, 'actual')).toBe('positive') + }) + + it('treats undefined source as actual and returns positive', () => { + expect(moneyTone(10)).toBe('positive') + expect(moneyTone(10, undefined)).toBe('positive') + }) + + it('returns planned for positive forecast values', () => { + expect(moneyTone(10, 'forecast')).toBe('planned') + }) + + it('returns negative for negative values regardless of source', () => { + expect(moneyTone(-10, 'actual')).toBe('negative') + expect(moneyTone(-10, 'forecast')).toBe('negative') + expect(moneyTone(-0.01)).toBe('negative') + }) + + it('returns neutral for zero', () => { + expect(moneyTone(0)).toBe('neutral') + expect(moneyTone(0, 'actual')).toBe('neutral') + expect(moneyTone(0, 'forecast')).toBe('neutral') + }) + + it('returns neutral for null and undefined', () => { + expect(moneyTone(null)).toBe('neutral') + expect(moneyTone(undefined)).toBe('neutral') + expect(moneyTone(null, 'forecast')).toBe('neutral') + }) +}) + +describe('eventTypeTone', () => { + it('maps each event type to a stable tone', () => { + expect(eventTypeTone('dividend')).toBeTruthy() + expect(eventTypeTone('coupon')).toBeTruthy() + expect(eventTypeTone('maturity')).toBeTruthy() + expect(eventTypeTone('offer')).toBeTruthy() + }) + + it('returns success for dividend events', () => { + expect(eventTypeTone('dividend')).toBe('success') + }) +}) + +describe('incomeTypeTone', () => { + it('returns a tone for each supported label', () => { + expect(incomeTypeTone('Дивиденд')).toBeTruthy() + expect(incomeTypeTone('Дивиденд (внешний)')).toBeTruthy() + expect(incomeTypeTone('Купон')).toBeTruthy() + }) + + it('marks plain dividends as success', () => { + expect(incomeTypeTone('Дивиденд')).toBe('success') + }) +}) + +describe('instrumentDisplay', () => { + it('uses ticker as main and skips subtitle when ticker is the only field', () => { + expect(instrumentDisplay({ ticker: 'AAPL' })).toEqual({ main: 'AAPL', subtitle: null }) + }) + + it('uses ticker as main and name as subtitle when both are present', () => { + expect(instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.' })).toEqual({ + main: 'AAPL', + subtitle: 'Apple Inc.', + }) + }) + + it('prefers name over description when both are provided alongside ticker', () => { + expect( + instrumentDisplay({ ticker: 'AAPL', name: 'Apple Inc.', description: 'Apple computer' }), + ).toEqual({ main: 'AAPL', subtitle: 'Apple Inc.' }) + }) + + it('uses name as main and skips subtitle when no ticker is provided', () => { + expect(instrumentDisplay({ name: 'Apple Inc.' })).toEqual({ + main: 'Apple Inc.', + subtitle: null, + }) + }) + + it('uses description as main when neither ticker nor name are provided', () => { + expect(instrumentDisplay({ description: 'Apple computer' })).toEqual({ + main: 'Apple computer', + subtitle: null, + }) + }) + + it('returns "—" as main when no fields are provided', () => { + expect(instrumentDisplay({})).toEqual({ main: '—', subtitle: null }) + expect(instrumentDisplay({ ticker: null, name: null, description: null })).toEqual({ + main: '—', + subtitle: null, + }) + }) + + it('does not duplicate subtitle when name equals ticker', () => { + expect(instrumentDisplay({ ticker: 'AAPL', name: 'AAPL' })).toEqual({ + main: 'AAPL', + subtitle: null, + }) + }) + + it('treats empty strings as missing', () => { + expect(instrumentDisplay({ ticker: '', name: '', description: '' })).toEqual({ + main: '—', + subtitle: null, + }) + }) +}) diff --git a/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts new file mode 100644 index 0000000..e1de1cb --- /dev/null +++ b/apps/frontend/src/widgets/broker-dashboard/lib/dashboardVisual.ts @@ -0,0 +1,71 @@ +import type { BrokerEventItem, BrokerMoney } from '@/shared/api' + +export type MoneyTone = 'positive' | 'negative' | 'planned' | 'neutral' + +export type TypeTone = 'neutral' | 'info' | 'success' | 'warning' + +export type DashboardMoneyLike = BrokerMoney | { currency: string; value: number } + +export type MoneySource = 'actual' | 'forecast' + +export function moneyTone(value: number | null | undefined, source?: MoneySource): MoneyTone { + if (value === null || value === undefined) return 'neutral' + if (value === 0) return 'neutral' + if (value < 0) return 'negative' + return source === 'forecast' ? 'planned' : 'positive' +} + +export function formatDashboardCurrency(money: DashboardMoneyLike | null | undefined): string { + if (!money) return '—' + const value = new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: 2, + }).format(money.value) + if (money.currency === 'RUB') return `${value}\u00a0₽` + return `${value}\u00a0${money.currency}` +} + +export function eventTypeTone(type: BrokerEventItem['type']): TypeTone { + switch (type) { + case 'dividend': + return 'success' + case 'coupon': + return 'info' + case 'maturity': + return 'warning' + case 'offer': + return 'neutral' + } +} + +type IncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон' + +export function incomeTypeTone(label: IncomeTypeLabel): TypeTone { + switch (label) { + case 'Дивиденд': + return 'success' + case 'Дивиденд (внешний)': + return 'info' + case 'Купон': + return 'warning' + } +} + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +export function instrumentDisplay(input: { + ticker?: string | null + name?: string | null + description?: string | null +}): { main: string; subtitle: string | null } { + const ticker = nonEmpty(input.ticker) + const name = nonEmpty(input.name) + const description = nonEmpty(input.description) + + const main = ticker ?? name ?? description ?? '—' + const subtitleCandidate = name ?? description + const subtitle = subtitleCandidate && subtitleCandidate !== main ? subtitleCandidate : null + + return { main, subtitle } +}