Description
Priority: High
Type: Feature Gap
Component: ModernDashboard
Created: 2025-07-05
Summary
The ModernDashboard component displays price changes but does not show PNL (Profit/Loss) metrics as specified in PRD Section 4. This is a critical missing feature that prevents users from understanding their portfolio performance beyond simple price movements.
Current State
The dashboard currently shows:
- 24h, 7d, 30d price changes (lines 272-278)
- Total portfolio value and SOL amount
- Individual token prices and balances
- Mock performance chart with simulated data
Expected Behavior (PRD Section 4)
According to PRD.md lines 47-51, the dashboard should display:
- Total PNL (USD and percentage) - Overall profit/loss across all wallets
- 24h, 7d, 30d PNL changes - Time-based PNL analysis showing gains/losses
- Individual wallet PNL breakdown - PNL per wallet (currently only shows balances)
- Top gainers/losers by token - Tokens with highest/lowest PNL
Technical Details
API Endpoints Already Available
The backend already provides PNL endpoints (PRD lines 68-75, 80):
GET /api/portfolio/pnl
- Combined PNL across all walletsGET /api/portfolio/pnl/timeframe/:period
- PNL for specific timeframeGET /api/wallets/pnl/:address
- Individual wallet PNLGET /api/wallets/pnl/:address/:period
- Wallet PNL for specific timeframeGET /api/tokens/pnl
- PNL breakdown by token
Database Tables Supporting PNL
The database schema includes (PRD lines 129-152):
wallet_pnl
table with realized/unrealized PNL datatoken_pnl
table with token-level PNL trackingportfolio_snapshots
for historical value tracking
Implementation Requirements
1. Add PNL Overview Card
Replace the current total value display (lines 38-67) with an enhanced PNL-aware version:
<\!-- Total Value & PNL Card -->
<div class="glass-card rounded-2xl p-8 mb-8 fade-in">
<div class="flex items-center justify-between mb-8">
<div>
<\!-- Total Value -->
<div class="flex items-center space-x-4 mb-4">
<h2 class="text-3xl font-bold text-white" id="total-value">$0.00</h2>
<span id="total-change" class="px-3 py-1 rounded-full text-sm font-medium"></span>
</div>
<\!-- PNL Display -->
<div class="flex items-center space-x-6 mb-2">
<div>
<p class="text-gray-400 text-xs mb-1">Total P&L</p>
<p class="text-xl font-semibold" id="total-pnl-usd">+$0.00</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-1">Total Return</p>
<p class="text-xl font-semibold" id="total-pnl-percent">+0.00%</p>
</div>
<div class="border-l border-gray-700 pl-6">
<p class="text-gray-400 text-xs mb-1">Unrealized</p>
<p class="text-sm font-medium" id="unrealized-pnl">$0.00</p>
</div>
<div>
<p class="text-gray-400 text-xs mb-1">Realized</p>
<p class="text-sm font-medium" id="realized-pnl">$0.00</p>
</div>
</div>
<p class="text-gray-400 text-sm">
<span id="sol-amount">0.00</span> SOL • Cost Basis: <span id="cost-basis">$0.00</span>
</p>
</div>
<\!-- Action buttons remain the same -->
</div>
<\!-- Performance Chart with PNL tracking -->
<div class="performance-chart rounded-xl p-4">
<canvas id="performance-chart" height="100"></canvas>
</div>
</div>
2. Replace Price Change Metrics with PNL Metrics
Update the metrics grid (lines 70-95) to show PNL instead of price changes:
<\!-- PNL Metrics -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<div class="glass-card rounded-xl p-4 fade-in">
<p class="text-gray-400 text-sm mb-1">24h P&L</p>
<div class="flex items-baseline space-x-2">
<p class="text-xl font-semibold" id="pnl-24h-usd">+$0.00</p>
<p class="text-sm" id="pnl-24h-percent">(+0.00%)</p>
</div>
</div>
<div class="glass-card rounded-xl p-4 fade-in">
<p class="text-gray-400 text-sm mb-1">7d P&L</p>
<div class="flex items-baseline space-x-2">
<p class="text-xl font-semibold" id="pnl-7d-usd">+$0.00</p>
<p class="text-sm" id="pnl-7d-percent">(+0.00%)</p>
</div>
</div>
<div class="glass-card rounded-xl p-4 fade-in">
<p class="text-gray-400 text-sm mb-1">30d P&L</p>
<div class="flex items-baseline space-x-2">
<p class="text-xl font-semibold" id="pnl-30d-usd">+$0.00</p>
<p class="text-sm" id="pnl-30d-percent">(+0.00%)</p>
</div>
</div>
<\!-- Keep existing wallet/token counts -->
</div>
3. Add Top Gainers/Losers Section
Insert a new section after the assets table:
<\!-- Top Gainers & Losers -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8">
<\!-- Top Gainers -->
<div class="glass-card rounded-2xl overflow-hidden fade-in">
<div class="px-6 py-4 border-b border-glass-border">
<h3 class="text-lg font-semibold text-white flex items-center">
<svg class="w-5 h-5 mr-2 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
</svg>
Top Gainers
</h3>
</div>
<div class="p-4">
<div id="top-gainers" class="space-y-3">
<\!-- Populated by JavaScript -->
</div>
</div>
</div>
<\!-- Top Losers -->
<div class="glass-card rounded-2xl overflow-hidden fade-in">
<div class="px-6 py-4 border-b border-glass-border">
<h3 class="text-lg font-semibold text-white flex items-center">
<svg class="w-5 h-5 mr-2 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"></path>
</svg>
Top Losers
</h3>
</div>
<div class="p-4">
<div id="top-losers" class="space-y-3">
<\!-- Populated by JavaScript -->
</div>
</div>
</div>
</div>
4. Update Wallet Table Headers
Modify the wallet table (line 167) to include PNL column:
<tr class="text-left text-gray-400 text-sm border-b border-glass-border">
<th class="px-6 py-4 font-medium">Address</th>
<th class="px-6 py-4 font-medium text-right">SOL Balance</th>
<th class="px-6 py-4 font-medium text-right">Token Value</th>
<th class="px-6 py-4 font-medium text-right">Total Value</th>
<th class="px-6 py-4 font-medium text-right">P&L</th>
<th class="px-6 py-4 font-medium text-right">Last Updated</th>
</tr>
5. JavaScript Implementation
Add these functions to handle PNL data:
// Fetch and display PNL data
async function loadPNLData() {
try {
// Fetch overall PNL
const pnlResponse = await fetch('/api/portfolio/pnl');
const pnlData = await pnlResponse.json();
if (pnlData.success) {
const pnl = pnlData.data;
// Update total PNL display
document.getElementById('total-pnl-usd').textContent = formatPNL(pnl.total_pnl_usd);
document.getElementById('total-pnl-percent').textContent = formatPercentage(pnl.total_pnl_percentage);
document.getElementById('unrealized-pnl').textContent = formatCurrency(pnl.unrealized_pnl_usd);
document.getElementById('realized-pnl').textContent = formatCurrency(pnl.realized_pnl_usd);
document.getElementById('cost-basis').textContent = formatCurrency(pnl.initial_value_usd);
// Apply color coding
applyPNLColors('total-pnl-usd', pnl.total_pnl_usd);
applyPNLColors('total-pnl-percent', pnl.total_pnl_percentage);
}
// Fetch timeframe PNL
await Promise.all([
loadTimeframePNL('24h'),
loadTimeframePNL('7d'),
loadTimeframePNL('30d')
]);
// Load top gainers/losers
await loadTopMovers();
} catch (error) {
console.error('Error loading PNL data:', error);
}
}
// Load PNL for specific timeframe
async function loadTimeframePNL(period) {
try {
const response = await fetch(\`/api/portfolio/pnl/timeframe/\${period}\`);
const result = await response.json();
if (result.success) {
const pnl = result.data;
const usdEl = document.getElementById(\`pnl-\${period}-usd\`);
const percentEl = document.getElementById(\`pnl-\${period}-percent\`);
usdEl.textContent = formatPNL(pnl.pnl_usd);
percentEl.textContent = \`(\${formatPercentage(pnl.pnl_percentage)})\`;
applyPNLColors(\`pnl-\${period}-usd\`, pnl.pnl_usd);
applyPNLColors(\`pnl-\${period}-percent\`, pnl.pnl_percentage);
}
} catch (error) {
console.error(\`Error loading \${period} PNL:\`, error);
}
}
// Load top gainers and losers
async function loadTopMovers() {
try {
const response = await fetch('/api/tokens/pnl');
const result = await response.json();
if (result.success) {
const tokens = result.data.tokens || [];
// Sort by PNL percentage
const sorted = tokens.sort((a, b) => b.pnl_percentage - a.pnl_percentage);
// Get top 5 gainers and losers
const gainers = sorted.slice(0, 5);
const losers = sorted.slice(-5).reverse();
displayTopMovers('top-gainers', gainers, true);
displayTopMovers('top-losers', losers, false);
}
} catch (error) {
console.error('Error loading top movers:', error);
}
}
// Display top movers in the UI
function displayTopMovers(containerId, tokens, isGainers) {
const container = document.getElementById(containerId);
container.innerHTML = '';
tokens.forEach(token => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3 rounded-lg hover:bg-white/5 transition-colors cursor-pointer';
item.onclick = () => window.location.href = \`/token/\${token.mint}\`;
item.innerHTML = \`
<div class="flex items-center space-x-3">
<div class="w-8 h-8 token-logo rounded-full flex items-center justify-center overflow-hidden">
\${token.imageUrl
? \`<img src="\${token.imageUrl}" alt="\${token.symbol}" class="w-full h-full object-cover" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">\`
: ''
}
<span class="text-xs font-medium \${token.imageUrl ? 'hidden' : 'flex'} w-full h-full items-center justify-center">\${token.symbol.substring(0, 3)}</span>
</div>
<div>
<p class="font-medium text-white">\${token.symbol}</p>
<p class="text-xs text-gray-400">\${formatCurrency(token.value)}</p>
</div>
</div>
<div class="text-right">
<p class="font-semibold \${isGainers ? 'text-green-400' : 'text-red-400'}">\${formatPNL(token.pnl_usd)}</p>
<p class="text-xs \${isGainers ? 'text-green-400' : 'text-red-400'}">\${formatPercentage(token.pnl_percentage)}</p>
</div>
\`;
container.appendChild(item);
});
}
// Format PNL with + sign for positive values
function formatPNL(value) {
const formatted = formatCurrency(Math.abs(value));
return value >= 0 ? \`+\${formatted}\` : \`-\${formatted}\`;
}
// Apply color coding to PNL elements
function applyPNLColors(elementId, value) {
const element = document.getElementById(elementId);
if (element) {
element.className = element.className.replace(/text-(green|red|gray)-400/g, '');
if (value > 0) {
element.classList.add('text-green-400');
} else if (value < 0) {
element.classList.add('text-red-400');
} else {
element.classList.add('text-gray-400');
}
}
}
// Update createWalletRow to include PNL
function createWalletRow(wallet) {
const row = document.createElement('tr');
row.className = 'border-b border-glass-border hover:bg-white/5 transition-colors cursor-pointer wallet-row';
row.setAttribute('data-searchable', wallet.wallet_address.toLowerCase());
row.onclick = (e) => {
if (\!e.target.closest('button')) {
window.location.href = \`/wallet/\${wallet.wallet_address}\`;
}
};
const tokens = JSON.parse(wallet.tokens || '[]');
const tokenValue = tokens.reduce((sum, token) => sum + (token.usdValue || 0), 0);
const solPrice = portfolioData?.solPrice || 150;
const totalValue = (wallet.sol_balance * solPrice) + tokenValue;
// Fetch wallet PNL data (you might want to batch this)
const pnl = wallet.pnl || { total_pnl_usd: 0, total_pnl_percentage: 0 };
row.innerHTML = \`
<td class="px-6 py-4">
<div class="flex items-center space-x-2">
<p class="font-mono text-sm text-white">\${wallet.wallet_address.substring(0, 6)}...\${wallet.wallet_address.slice(-4)}</p>
<button onclick="copyAddress('\${wallet.wallet_address}')" class="text-gray-400 hover:text-white">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
</svg>
</button>
</div>
</td>
<td class="px-6 py-4 text-right">
<p class="text-white">\${formatNumber(wallet.sol_balance, 4)} SOL</p>
</td>
<td class="px-6 py-4 text-right">
<p class="text-white">\${formatCurrency(tokenValue)}</p>
</td>
<td class="px-6 py-4 text-right">
<p class="text-white font-medium">\${formatCurrency(totalValue)}</p>
</td>
<td class="px-6 py-4 text-right">
<p class="\${pnl.total_pnl_usd >= 0 ? 'text-green-400' : 'text-red-400'} font-medium">
\${formatPNL(pnl.total_pnl_usd)}
<span class="text-xs ml-1">(\${formatPercentage(pnl.total_pnl_percentage)})</span>
</p>
</td>
<td class="px-6 py-4 text-right">
<p class="text-gray-400 text-sm">\${new Date(wallet.last_updated).toLocaleDateString()}</p>
</td>
\`;
return row;
}
// Update the main updateDashboard function to include PNL loading
async function updateDashboard(data) {
portfolioData = data;
// ... existing code ...
// Load PNL data
await loadPNLData();
// Load assets and wallets
await Promise.all([
loadAssets(),
loadWallets()
]);
}
Data Structures
Expected API Response Formats
// GET /api/portfolio/pnl
interface PortfolioPNL {
initial_value_usd: number;
current_value_usd: number;
realized_pnl_usd: number;
unrealized_pnl_usd: number;
total_pnl_usd: number;
total_pnl_percentage: number;
last_updated: string;
}
// GET /api/portfolio/pnl/timeframe/:period
interface TimeframePNL {
period: '24h' | '7d' | '30d';
start_value: number;
end_value: number;
pnl_usd: number;
pnl_percentage: number;
}
// GET /api/tokens/pnl
interface TokenPNL {
tokens: Array<{
mint: string;
symbol: string;
name: string;
imageUrl: string;
initial_amount: number;
current_amount: number;
initial_value: number;
current_value: number;
pnl_usd: number;
pnl_percentage: number;
}>;
}
UI/UX Considerations
-
Color Coding:
- Positive PNL:
text-green-400
for light green - Negative PNL:
text-red-400
for light red - Neutral/Zero:
text-gray-400
- Positive PNL:
-
Number Formatting:
- Always show + sign for positive PNL
- Use parentheses for percentages:
+$1,234.56 (+12.34%)
- Show 2 decimal places for USD values
- Show 2 decimal places for percentages
-
Loading States:
- Show skeleton loaders while PNL data loads
- Display cached data with "stale" indicator if fresh data unavailable
-
Interactive Elements:
- Click on any PNL metric to see detailed breakdown
- Hover tooltips explaining realized vs unrealized
- Click tokens in top movers to navigate to token detail page
-
Responsive Design:
- Stack PNL metrics vertically on mobile
- Hide realized/unrealized breakdown on small screens
- Show condensed top movers (3 each) on mobile
Integration Points
-
With Performance Chart:
- Update chart to show PNL over time instead of just value
- Add toggle between "Value" and "PNL" view
- Use portfolio snapshots for accurate historical data
-
With Refresh Function:
- Include PNL data refresh in
refreshPortfolio()
- Show loading spinner on PNL sections during refresh
- Include PNL data refresh in
-
With Search/Filter:
- Allow filtering wallets by positive/negative PNL
- Search tokens by PNL performance
Acceptance Criteria
- Dashboard displays total portfolio PNL in USD and percentage
- Realized and unrealized PNL shown separately
- 24h, 7d, 30d metrics show PNL changes with USD and percentage
- Top gainers/losers section displays at least 5 tokens each
- Wallet table includes PNL column with proper formatting
- Performance chart shows real historical PNL data
- All PNL values are color-coded (green positive, red negative)
- PNL data refreshes with portfolio refresh
- Loading states shown while fetching PNL data
- Error handling for failed PNL API calls
- Mobile responsive layout maintained
Notes
- PNL calculation methodology is defined in PRD Section 8 (lines 226-257)
- Use FIFO cost basis calculation as specified
- Handle missing historical data with interpolation
- Ensure PNL displays update in real-time with price changes
- Consider caching PNL calculations for performance
- Batch wallet PNL requests to avoid N+1 queries