feat(frontend): add dashboard visual helpers for HTML parity

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.
This commit is contained in:
Sergey Krylov 2026-06-27 12:46:39 +03:00
parent ae207b0cb3
commit af0aaeda9d
5 changed files with 287 additions and 17 deletions

View File

@ -69,3 +69,9 @@ export function incomeTypesToOperationTypes(types: DashboardIncomeType[]): strin
if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON') if (types.includes('coupon')) operationTypes.add('OPERATION_TYPE_COUPON')
return [...operationTypes].join(',') return [...operationTypes].join(',')
} }
export function resetDashboardFilters<T extends string>(
factory: () => DashboardFilterState<T>,
): DashboardFilterState<T> {
return factory()
}

View File

@ -56,6 +56,39 @@ describe('dashboardIncome', () => {
expect(rows[1].amount.value).toBe(5) 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', () => { it('sums displayed income rows by currency', () => {
const rows = getDashboardIncomeRows([ const rows = getDashboardIncomeRows([
operation('OPERATION_TYPE_DIVIDEND', 10), operation('OPERATION_TYPE_DIVIDEND', 10),

View File

@ -1,4 +1,5 @@
import type { BrokerMoney, BrokerOperation } from '@/shared/api' import type { BrokerMoney, BrokerOperation } from '@/shared/api'
import { instrumentDisplay } from './dashboardVisual'
const INCOME_TYPES = new Set([ const INCOME_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIVIDEND',
@ -6,11 +7,15 @@ const INCOME_TYPES = new Set([
'OPERATION_TYPE_COUPON', 'OPERATION_TYPE_COUPON',
]) ])
export type DashboardIncomeTypeLabel = 'Дивиденд' | 'Дивиденд (внешний)' | 'Купон'
export type DashboardIncomeRow = { export type DashboardIncomeRow = {
id: string id: string
date: string | null date: string | null
instrumentMain: string
instrumentSubtitle: string | null
instrument: string instrument: string
typeLabel: 'Дивиденд' | 'Купон' typeLabel: DashboardIncomeTypeLabel
amount: BrokerMoney amount: BrokerMoney
} }
@ -18,25 +23,29 @@ export function isDashboardIncomeOperation(operation: BrokerOperation): boolean
return INCOME_TYPES.has(operation.type) && operation.payment !== null return INCOME_TYPES.has(operation.type) && operation.payment !== null
} }
function typeLabel(type: string): DashboardIncomeRow['typeLabel'] { function typeLabel(type: string): DashboardIncomeTypeLabel {
return type === 'OPERATION_TYPE_COUPON' ? 'Купон' : 'Дивиденд' if (type === 'OPERATION_TYPE_COUPON') return 'Купон'
if (type === 'OPERATION_TYPE_DIV_EXT') return 'Дивиденд (внешний)'
return 'Дивиденд'
} }
export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] { export function getDashboardIncomeRows(operations: BrokerOperation[]): DashboardIncomeRow[] {
return operations.filter(isDashboardIncomeOperation).map((operation) => ({ return operations.filter(isDashboardIncomeOperation).map((operation) => {
id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`), const { main, subtitle } = instrumentDisplay({
date: typeof operation.date === 'string' ? operation.date : null, ticker: operation.ticker,
instrument: name: operation.name,
typeof operation.ticker === 'string' description: operation.description,
? operation.ticker })
: typeof operation.name === 'string' return {
? operation.name id: String(operation.id ?? operation.cursor ?? `${operation.type}-${operation.date}`),
: typeof operation.description === 'string' date: typeof operation.date === 'string' ? operation.date : null,
? operation.description instrumentMain: main,
: '—', instrumentSubtitle: subtitle,
typeLabel: typeLabel(operation.type), instrument: main,
amount: operation.payment!, typeLabel: typeLabel(operation.type),
})) amount: operation.payment!,
}
})
} }
export function sumDashboardIncome( export function sumDashboardIncome(

View File

@ -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,
})
})
})

View File

@ -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 }
}