From 0aedb5631f741ee33e7a7e24e373c8572a50fd5b Mon Sep 17 00:00:00 2001 From: nomsoscript Date: Sat, 25 Jul 2026 15:42:46 +0100 Subject: [PATCH] feat(frontend): improve portfolio chart loading and i18n --- frontend/messages/en.json | 10 + frontend/messages/es.json | 10 + frontend/messages/pt.json | 10 + .../components/PortfolioChartWidget.test.tsx | 257 ++++------- .../src/components/PortfolioChartWidget.tsx | 417 +++++++++++------- 5 files changed, 370 insertions(+), 334 deletions(-) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b7272c71..aa7d7cd6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -573,5 +573,15 @@ "successTitle": "KYC Submitted Successfully!", "successDescription": "Your KYC verification has been submitted and is under review.", "dash": "—" + }, + "portfolioChartWidget": { + "title": "Portfolio Value", + "allocation": "Allocation", + "trend": "Trend", + "loading": "Loading portfolio chart...", + "historyLoading": "Loading performance history...", + "emptyAllocation": "No allocation data available yet.", + "emptyAssets": "No portfolio assets available yet.", + "emptyHistory": "No performance history available yet." } } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 84cdd26b..4d2cb831 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -573,5 +573,15 @@ "successTitle": "KYC enviado exitosamente!", "successDescription": "Tu verificacion KYC ha sido enviada y esta en revision.", "dash": "—" + }, + "portfolioChartWidget": { + "title": "Valor del portafolio", + "allocation": "Distribucion", + "trend": "Tendencia", + "loading": "Cargando grafico del portafolio...", + "historyLoading": "Cargando historial de rendimiento...", + "emptyAllocation": "Todavia no hay datos de asignacion.", + "emptyAssets": "Todavia no hay activos en el portafolio.", + "emptyHistory": "Todavia no hay historial de rendimiento." } } diff --git a/frontend/messages/pt.json b/frontend/messages/pt.json index 1a6992e1..b18cf4f8 100644 --- a/frontend/messages/pt.json +++ b/frontend/messages/pt.json @@ -573,5 +573,15 @@ "successTitle": "KYC enviado com sucesso!", "successDescription": "Sua verificacao KYC foi enviada e esta em revisao.", "dash": "—" + }, + "portfolioChartWidget": { + "title": "Valor do portafolio", + "allocation": "Distribuicao", + "trend": "Tendencia", + "loading": "Carregando grafico do portafolio...", + "historyLoading": "Carregando historico de desempenho...", + "emptyAllocation": "Ainda nao ha dados de alocacao.", + "emptyAssets": "Ainda nao ha ativos no portafolio.", + "emptyHistory": "Ainda nao ha historico de desempenho." } } diff --git a/frontend/src/components/PortfolioChartWidget.test.tsx b/frontend/src/components/PortfolioChartWidget.test.tsx index 720229bd..1200a9ae 100644 --- a/frontend/src/components/PortfolioChartWidget.test.tsx +++ b/frontend/src/components/PortfolioChartWidget.test.tsx @@ -1,16 +1,48 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { PortfolioChartWidget, PortfolioAsset } from './PortfolioChartWidget'; -// Mock recharts to avoid canvas issues in tests +const translations = { + en: { + portfolioChartWidget: { + title: 'Portfolio Value', + allocation: 'Allocation', + trend: 'Trend', + loading: 'Loading portfolio chart...', + historyLoading: 'Loading performance history...', + emptyAllocation: 'No allocation data available yet.', + emptyAssets: 'No portfolio assets available yet.', + emptyHistory: 'No performance history available yet.', + }, + }, + es: { + portfolioChartWidget: { + title: 'Valor del portafolio', + allocation: 'Distribucion', + trend: 'Tendencia', + loading: 'Cargando grafico del portafolio...', + historyLoading: 'Cargando historial de rendimiento...', + emptyAllocation: 'Todavia no hay datos de asignacion.', + emptyAssets: 'Todavia no hay activos en el portafolio.', + emptyHistory: 'Todavia no hay historial de rendimiento.', + }, + }, +} as const; + +let mockLocale: keyof typeof translations = 'en'; + +vi.mock('next-intl', () => ({ + useLocale: () => mockLocale, + useTranslations: (namespace: keyof (typeof translations)['en']) => (key: string) => + translations[mockLocale][namespace][key as keyof (typeof translations)['en'][typeof namespace]] ?? key, +})); + vi.mock('recharts', () => ({ PieChart: ({ children }: any) =>
{children}
, Pie: ({ children, onClick, data }: any) => ( -
onClick && onClick(data[0])} - > +
onClick && data?.[0] && onClick(data[0])}> {children}
), @@ -49,6 +81,11 @@ describe('PortfolioChartWidget', () => { }, ]; + const historyData = [ + { timestamp: Date.UTC(2026, 0, 1), value: 3200 }, + { timestamp: Date.UTC(2026, 0, 2), value: 4000 }, + ]; + const defaultProps = { assets: mockAssets, totalValue: 4000, @@ -58,103 +95,53 @@ describe('PortfolioChartWidget', () => { beforeEach(() => { vi.clearAllMocks(); + mockLocale = 'en'; }); - it('renders the component with portfolio value', () => { + it('renders the localized title and portfolio value', () => { render(); expect(screen.getByText('Portfolio Value')).toBeInTheDocument(); expect(screen.getByText('$4,000.00')).toBeInTheDocument(); }); - it('displays the correct currency format', () => { - render( - - ); - - // The component should format currency, checking for the value in the DOM - const portfolioValue = screen.getByText(/Portfolio Value/i).parentElement; - expect(portfolioValue).toBeInTheDocument(); - }); - it('renders all assets in the list', () => { render(); expect(screen.getByText('XLM')).toBeInTheDocument(); expect(screen.getByText('USDC')).toBeInTheDocument(); - }); - - it('displays asset percentages correctly', () => { - render(); - - const percentageElements = screen.getAllByText(/50\.0%/); - expect(percentageElements.length).toBeGreaterThan(0); - }); - - it('displays asset amounts', () => { - render(); - expect(screen.getByText('1000.0000 XLM')).toBeInTheDocument(); - expect(screen.getByText('500.0000 USDC')).toBeInTheDocument(); }); - it('switches between chart types when buttons are clicked', async () => { - render(); + it('switches to the history view when history data is available', async () => { + render(); - const trendButton = screen.getByText('Trend'); - fireEvent.click(trendButton); + fireEvent.click(screen.getByRole('button', { name: 'Trend' })); await waitFor(() => { expect(screen.getByTestId('line-chart')).toBeInTheDocument(); }); - - const allocationButton = screen.getByText('Allocation'); - fireEvent.click(allocationButton); - - await waitFor(() => { - expect(screen.getByTestId('pie-chart')).toBeInTheDocument(); - }); }); - it('calls onAssetClick when an asset is clicked', () => { - const onAssetClick = vi.fn(); - render( - - ); + it('shows an accessible loading state and disables chart toggles', () => { + render(); - const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]'); - if (assetElement) { - fireEvent.click(assetElement); - } - - // Should be called (exact behavior depends on component implementation) - expect(screen.getByText('XLM')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Loading portfolio chart...'); + expect(screen.getByRole('button', { name: 'Allocation' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Trend' })).toBeDisabled(); }); - it('toggles asset selection on click', () => { - render(); - - const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]'); + it('shows an empty history state when no trend data exists', async () => { + render(); - if (assetElement) { - fireEvent.click(assetElement); - // Check that the element has selected styling (bg-blue-50) - expect(assetElement).toHaveClass('bg-blue-50'); + fireEvent.click(screen.getByRole('button', { name: 'Trend' })); - fireEvent.click(assetElement); - // The selection might be toggled off - expect(assetElement).toBeInTheDocument(); - } + await waitFor(() => { + expect(screen.getByText('No performance history available yet.')).toBeInTheDocument(); + }); }); - it('renders with empty assets array', () => { + it('shows the empty assets state when no assets are provided', () => { render( { /> ); - expect(screen.getByText('Portfolio Value')).toBeInTheDocument(); - expect(screen.getByText('$0.00')).toBeInTheDocument(); + expect(screen.getByText('No allocation data available yet.')).toBeInTheDocument(); + expect(screen.getByText('No portfolio assets available yet.')).toBeInTheDocument(); }); - it('assigns colors from palette to assets without color', () => { - const assetsWithoutColor: PortfolioAsset[] = [ - { - id: '1', - symbol: 'XLM', - name: 'Stellar', - amount: 100, - value: 1000, - percentage: 50, - }, - { - id: '2', - symbol: 'USDC', - name: 'USD Coin', - amount: 100, - value: 1000, - percentage: 50, - }, - ]; + it('renders a locale-aware translation and currency format', () => { + mockLocale = 'es'; + const formattedValue = new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(5000); render( - - ); - - expect(screen.getByText('XLM')).toBeInTheDocument(); - expect(screen.getByText('USDC')).toBeInTheDocument(); - }); - - it('handles custom className', () => { - const { container } = render( ); - const mainDiv = container.querySelector('.custom-class'); - expect(mainDiv).toBeInTheDocument(); + expect(screen.getByText('Valor del portafolio')).toBeInTheDocument(); + expect(screen.getByText(formattedValue)).toBeInTheDocument(); }); - it('respects showAnimation prop', () => { - const { rerender } = render( + it('handles asset selection and onAssetClick callbacks', () => { + const onAssetClick = vi.fn(); + render( ); - expect(screen.getByText('Portfolio Value')).toBeInTheDocument(); + const assetElement = screen.getByText('XLM').closest('div[class*="p-3"]'); + expect(assetElement).toBeInTheDocument(); - rerender( - - ); + if (assetElement) { + fireEvent.click(assetElement); + expect(assetElement).toHaveClass('bg-blue-50'); + } - expect(screen.getByText('Portfolio Value')).toBeInTheDocument(); + expect(onAssetClick).toHaveBeenCalledWith(mockAssets[0]); }); - it('handles currency formatting for different currencies', () => { - const { rerender } = render( - - ); - - expect(screen.getByText('$1,000.00')).toBeInTheDocument(); - - rerender( + it('renders an error message when provided', () => { + render( ); - // Should render with different currency formatting - expect(screen.getByText(/Portfolio Value/)).toBeInTheDocument(); - }); - - it('handles large portfolio values', () => { - const largeAssets: PortfolioAsset[] = [ - { - id: '1', - symbol: 'BTC', - name: 'Bitcoin', - amount: 0.5, - value: 20000, - percentage: 100, - }, - ]; - - const { container } = render( - + expect(screen.getByRole('alert')).toHaveTextContent( + 'Unable to load the latest portfolio snapshot.' ); - - // Find the total portfolio value (first $20,000.00 in the portfolio value section) - const portfolioValueTexts = screen.getAllByText('$20,000.00'); - expect(portfolioValueTexts.length).toBeGreaterThan(0); - }); - - it('displays asset color indicators', () => { - render(); - - const colorDots = screen.getAllByTestId('cell').length; - // Should have color cells for each asset - expect(colorDots).toBeGreaterThanOrEqual(0); }); }); diff --git a/frontend/src/components/PortfolioChartWidget.tsx b/frontend/src/components/PortfolioChartWidget.tsx index 17cf8de7..0c2a556b 100644 --- a/frontend/src/components/PortfolioChartWidget.tsx +++ b/frontend/src/components/PortfolioChartWidget.tsx @@ -2,6 +2,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { motion, AnimatePresence, type Variants } from 'framer-motion'; +import { useLocale, useTranslations } from 'next-intl'; import { PieChart, Pie, @@ -15,6 +16,7 @@ import { YAxis, CartesianGrid, } from 'recharts'; +import { localeToLanguageTag } from '@/i18n/config'; export interface PortfolioAsset { id: string; @@ -26,48 +28,52 @@ export interface PortfolioAsset { color?: string; } +export interface PortfolioHistoryPoint { + timestamp: number; + value: number; +} + export interface PortfolioChartProps { assets: PortfolioAsset[]; totalValue: number; currency?: string; showAnimation?: boolean; + loading?: boolean; + historyData?: PortfolioHistoryPoint[]; + historyLoading?: boolean; + error?: string | null; onAssetClick?: (asset: PortfolioAsset) => void; className?: string; } -export interface PortfolioHistoryPoint { - timestamp: number; - value: number; -} - -// Default color palette for assets const DEFAULT_COLORS = [ - '#3B82F6', // Blue - '#10B981', // Green - '#F59E0B', // Amber - '#EF4444', // Red - '#8B5CF6', // Purple - '#EC4899', // Pink - '#14B8A6', // Teal - '#F97316', // Orange + '#3B82F6', + '#10B981', + '#F59E0B', + '#EF4444', + '#8B5CF6', + '#EC4899', + '#14B8A6', + '#F97316', ]; -/** - * PortfolioChartWidget - A responsive portfolio visualization component - * Displays asset allocation with pie chart and includes state management - */ export function PortfolioChartWidget({ assets = [], totalValue = 0, currency = 'USD', showAnimation = true, + loading = false, + historyData = [], + historyLoading = false, + error = null, onAssetClick, className = '', }: PortfolioChartProps) { + const t = useTranslations('portfolioChartWidget'); + const locale = localeToLanguageTag(useLocale()); const [selectedAsset, setSelectedAsset] = useState(null); const [chartType, setChartType] = useState<'pie' | 'history'>('pie'); - // Add colors to assets if not provided const assetsWithColors = useMemo(() => { return assets.map((asset, index) => ({ ...asset, @@ -75,16 +81,24 @@ export function PortfolioChartWidget({ })); }, [assets]); - // Prepare data for pie chart const pieData = useMemo(() => { - return assetsWithColors.map(asset => ({ + return assetsWithColors.map((asset) => ({ name: asset.symbol, value: asset.value, payload: asset, })); }, [assetsWithColors]); - // Handle asset selection + const historyChartData = useMemo(() => { + return historyData.map((point) => ({ + ...point, + label: new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + }).format(new Date(point.timestamp)), + })); + }, [historyData, locale]); + const handleAssetClick = useCallback( (asset: PortfolioAsset) => { setSelectedAsset(asset.id === selectedAsset ? null : asset.id); @@ -93,20 +107,31 @@ export function PortfolioChartWidget({ [selectedAsset, onAssetClick] ); - // Format currency values const formatCurrency = useCallback( (value: number) => { - return new Intl.NumberFormat('en-US', { + return new Intl.NumberFormat(locale, { style: 'currency', - currency: currency, + currency, minimumFractionDigits: 2, maximumFractionDigits: 2, }).format(value); }, - [currency] + [currency, locale] + ); + + const formatCompactCurrency = useCallback( + (value: number) => { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + notation: 'compact', + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }).format(value); + }, + [currency, locale] ); - // Container animation variants const containerVariants: Variants = { hidden: { opacity: 0 }, visible: { @@ -131,180 +156,240 @@ export function PortfolioChartWidget({ }, }; + const isChartLoading = loading || (chartType === 'history' && historyLoading); + const hasAssets = assetsWithColors.length > 0; + const hasHistoryData = historyChartData.length > 0; + return ( - {/* Header */} - +

- Portfolio Value + {t('title')}

-

+

{formatCurrency(totalValue)}

setChartType('pie')} - className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${ + disabled={isChartLoading} + className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${ chartType === 'pie' ? 'bg-blue-600 text-white' - : 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300' - }`} + : 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' + } ${isChartLoading ? 'cursor-not-allowed opacity-60' : ''}`} + aria-pressed={chartType === 'pie'} > - Allocation + {t('allocation')} setChartType('history')} - className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${ + disabled={isChartLoading} + className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${ chartType === 'history' ? 'bg-blue-600 text-white' - : 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300' - }`} + : 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' + } ${isChartLoading ? 'cursor-not-allowed opacity-60' : ''}`} + aria-pressed={chartType === 'history'} > - Trend + {t('trend')}
- {/* Chart Container */} - - {chartType === 'pie' ? ( - - - - handleAssetClick(entry.payload.payload)} - > - {assetsWithColors.map((asset) => ( - - ))} - - formatCurrency(value as number)} - contentStyle={{ - backgroundColor: '#1F2937', - border: '1px solid #374151', - borderRadius: '0.375rem', - color: '#F3F4F6', - }} - /> - { - const asset = (entry as unknown as { payload: { payload: PortfolioAsset } }).payload.payload; - return `${asset.symbol} (${asset.percentage.toFixed(1)}%)`; - }} - wrapperStyle={{ - paddingTop: '20px', - }} - /> - - - - ) : ( - - - - - - - - - - - - )} - - + {isChartLoading && ( +
+
+

+ {chartType === 'history' && historyLoading ? t('historyLoading') : t('loading')} +

+
+ )} - {/* Asset List */} - - {assetsWithColors.map((asset) => ( - handleAssetClick(asset)} - className={`flex items-center gap-3 p-3 rounded-md cursor-pointer transition-all ${ - selectedAsset === asset.id - ? 'bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700' - : 'bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700' - }`} - whileHover={{ x: 4 }} - whileTap={{ scale: 0.98 }} + {error ? ( +
- -
-
- - {asset.symbol} - - - {asset.percentage.toFixed(1)}% - -
-
- - {asset.amount.toFixed(4)} {asset.symbol} - - - {formatCurrency(asset.value)} - + {error} +
+ ) : chartType === 'pie' && !hasAssets && !loading ? ( +

+ {t('emptyAllocation')} +

+ ) : chartType === 'history' && !hasHistoryData && !historyLoading ? ( +

+ {t('emptyHistory')} +

+ ) : ( + + {chartType === 'pie' ? ( + + + + handleAssetClick(entry.payload.payload)} + > + {assetsWithColors.map((asset) => ( + + ))} + + formatCurrency(value as number)} + contentStyle={{ + backgroundColor: '#1F2937', + border: '1px solid #374151', + borderRadius: '0.375rem', + color: '#F3F4F6', + }} + /> + { + const asset = (entry as { payload: { payload: PortfolioAsset } }).payload.payload; + return `${asset.symbol} (${asset.percentage.toFixed(1)}%)`; + }} + wrapperStyle={{ paddingTop: '20px' }} + /> + + + + ) : ( + + + + + + formatCompactCurrency(value)} /> + formatCurrency(value as number)} + contentStyle={{ + borderRadius: '0.375rem', + border: '1px solid #E5E7EB', + }} + /> + + + + + )} + + )} + + + + {loading && !hasAssets ? ( + Array.from({ length: 3 }).map((_, index) => ( +