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:
parent
ae207b0cb3
commit
af0aaeda9d
@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user