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',
- }}
- />
-
-
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
- )}
-
-
+ {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',
+ }}
+ />
+
+
+
+ ) : (
+
+
+
+
+
+ formatCompactCurrency(value)} />
+ formatCurrency(value as number)}
+ contentStyle={{
+ borderRadius: '0.375rem',
+ border: '1px solid #E5E7EB',
+ }}
+ />
+
+
+
+
+ )}
+
+ )}
+
+
+
+ {loading && !hasAssets ? (
+ Array.from({ length: 3 }).map((_, index) => (
+
-
- ))}
+ ))
+ ) : !hasAssets ? (
+
+ {t('emptyAssets')}
+
+ ) : (
+ assetsWithColors.map((asset) => (
+
!isChartLoading && handleAssetClick(asset)}
+ className={`flex cursor-pointer items-center gap-3 rounded-md p-3 transition-all ${
+ selectedAsset === asset.id
+ ? 'border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30'
+ : 'bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700'
+ } ${isChartLoading ? 'pointer-events-none opacity-60' : ''}`}
+ whileHover={isChartLoading ? undefined : { x: 4 }}
+ whileTap={isChartLoading ? undefined : { scale: 0.98 }}
+ >
+
+
+
+
+ {asset.symbol}
+
+
+ {asset.percentage.toFixed(1)}%
+
+
+
+
+ {asset.amount.toFixed(4)} {asset.symbol}
+
+
+ {formatCurrency(asset.value)}
+
+
+
+
+ ))
+ )}
);