Skip to content

Dashboard Missing PNL Display #6

Open
@wtfsayo

Description

@wtfsayo

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 wallets
  • GET /api/portfolio/pnl/timeframe/:period - PNL for specific timeframe
  • GET /api/wallets/pnl/:address - Individual wallet PNL
  • GET /api/wallets/pnl/:address/:period - Wallet PNL for specific timeframe
  • GET /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 data
  • token_pnl table with token-level PNL tracking
  • portfolio_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

  1. Color Coding:

    • Positive PNL: text-green-400 for light green
    • Negative PNL: text-red-400 for light red
    • Neutral/Zero: text-gray-400
  2. 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
  3. Loading States:

    • Show skeleton loaders while PNL data loads
    • Display cached data with "stale" indicator if fresh data unavailable
  4. 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
  5. 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

  1. 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
  2. With Refresh Function:

    • Include PNL data refresh in refreshPortfolio()
    • Show loading spinner on PNL sections during refresh
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions