Files
dione/index.html
Dione c573e58e0f feat: add neon color picker for wallet management
- Replace muted Tailwind palette with 16-color neon palette
- Add clickable color dot in sidebar that opens a popup swatch grid
- Allow color override for all wallets (including embedded)
- Fix getColorForWallet to respect localStorage override
- Fix duplicate renameWallet in wallets.js
- Update Dockerfile to serve ESM modules via nginx
2026-06-10 07:10:24 +00:00

1441 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>cbBTC Treasury Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
<script type="module">
import { WalletManager } from './wallets.js';
window.WalletManager = WalletManager;
</script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background-color: #05070B; }
/* Sidebar */
.sidebar-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
.sidebar-overlay.open { opacity: 1; pointer-events: auto; }
.sidebar-panel { position: fixed; top: 0; right: 0; width: 320px; height: 100vh; background: #090D14; border-left: 1px solid #1A1F2C; z-index: 50; transform: translateX(100%); transition: transform 0.3s; overflow-y: auto; }
.sidebar-panel.open { transform: translateX(0); }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 60; display: none; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal-box { background: #090D14; border: 1px solid #1A1F2C; border-radius: 16px; width: 400px; max-width: 90vw; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); }
.apexcharts-tooltip { background: #111827 !important; border: 1px solid #1F2937 !important; border-radius: 12px !important; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5) !important; padding: 10px 16px !important; color: white !important; }
.apexcharts-xaxistooltip { display: none !important; }
.apexcharts-marker { stroke-width: 3px !important; fill: #111827 !important; }
.badge { padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; }
.badge-buy { background: rgba(31, 111, 235, 0.2); color: #58a6ff; border: 1px solid rgba(31, 111, 235, 0.3); }
.badge-sell { background: rgba(248, 81, 73, 0.2); color: #ff7b72; border: 1px solid rgba(248, 81, 73, 0.3); }
.badge-aavein { background: rgba(137, 87, 229, 0.2); color: #bc8cff; border: 1px solid rgba(137, 87, 229, 0.3); }
.badge-aaveout { background: rgba(210, 153, 34, 0.2); color: #d29922; border: 1px solid rgba(210, 153, 34, 0.3); }
.filter-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 4px; width: fit-content; }
.filter-item { display: flex; align-items: center; padding: 4px; background: rgba(255, 255, 255, 0.03); border-radius: 4px; transition: all 0.2s; cursor: pointer; }
.filter-item:hover { background: rgba(255, 255, 255, 0.08); }
.filter-checkbox { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
.filter-checkbox:checked { border-color: transparent; }
.filter-checkbox.buy:checked { background: #58a6ff; box-shadow: 0 0 8px rgba(88, 166, 255, 0.4); }
.filter-checkbox.sell:checked { background: #ff7b72; box-shadow: 0 0 8px rgba(255, 123, 114, 0.4); }
.filter-checkbox.aavein:checked { background: #bc8cff; box-shadow: 0 0 8px rgba(188, 140, 255, 0.4); }
.filter-checkbox.aaveout:checked { background: #d29922; box-shadow: 0 0 8px rgba(210, 153, 34, 0.4); }
.wallet-filter-toggle { appearance: none; width: 12px; height: 12px; border-radius: 2px; border: 1px solid rgba(255,255,255,0.2); cursor: pointer; position: relative; transition: all 0.2s; }
.wallet-filter-toggle:checked { background: var(--wallet-color); border-color: transparent; box-shadow: 0 0 8px color-mix(in srgb, var(--wallet-color) 40%, transparent); }
.wallet-color-dot {
width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
flex-shrink: 0; transition: transform 0.15s, box-shadow 0.15s;
}
.wallet-color-dot:hover {
transform: scale(1.25); box-shadow: 0 0 10px var(--wallet-color);
}
.color-picker-overlay {
position: fixed; inset: 0; z-index: 70; display: none; background: rgba(0,0,0,0.5);
}
.color-picker-overlay.open { display: block; }
.color-picker-popup {
position: absolute; background: #090D14; border: 1px solid #1A1F2C;
border-radius: 12px; padding: 12px; box-shadow: 0 16px 40px rgba(0,0,0,0.7);
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
z-index: 80; width: 210px;
}
.color-picker-swatch {
width: 36px; height: 36px; border-radius: 50%; cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
border: 2px solid transparent;
}
.color-picker-swatch:hover {
transform: scale(1.2); border-color: rgba(255,255,255,0.3);
box-shadow: 0 0 12px var(--swatch-color);
}
.color-picker-swatch.selected {
border-color: #fff; box-shadow: 0 0 14px var(--swatch-color);
}
.ledger-header { display: flex; align-items: center; }
.ledger-title-col { width: 260px; flex-shrink: 0; }
.col-timestamp { width: 140px; flex-shrink: 0; }
.col-action { width: 150px; flex-shrink: 0; }
.col-wallet { width: 120px; flex-shrink: 0; }
.col-value { width: auto; }
.tooltip-cell { position: relative; }
.tooltip-cell .tooltip-box {
display: none; position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%);
z-index: 100; background: #111827; border: 1px solid #374151; border-radius: 8px;
padding: 10px 14px; box-shadow: 0 10px 25px rgba(0,0,0,0.6);
min-width: 200px; font-size: 12px; white-space: nowrap;
}
.tooltip-cell:hover .tooltip-box { display: block; }
.tooltip-cell .tooltip-box::after {
content: ''; position: absolute; bottom: 100%; left: 50%; transform: translateX(-50%);
border: 6px solid; border-color: #374151 transparent transparent transparent;
}
.tooltip-row { display: flex; justify-content: space-between; gap: 16px; padding: 2px 0; }
.tooltip-row.total { border-top: 1px solid #374151; margin-top: 6px; padding-top: 6px; font-weight: 700; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #05070B; }
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #374151; }
</style>
</head>
<body class="p-4 md:p-8 min-h-screen text-white">
<!-- Sidebar Overlay -->
<div id="sidebar-overlay" class="sidebar-overlay" onclick="closeSidebar()"></div>
<!-- Sidebar Panel -->
<div id="sidebar-panel" class="sidebar-panel">
<div class="p-4 border-b border-[#1A1F2C] flex items-center justify-between">
<h2 class="text-sm font-bold text-gray-400 uppercase tracking-widest">Wallet Management</h2>
<button onclick="closeSidebar()" class="text-gray-500 hover:text-white transition"></button>
</div>
<div id="sidebar-wallet-list" class="p-4 space-y-3"></div>
<div class="p-4 border-t border-[#1A1F2C]">
<button onclick="openAddWalletModal()" class="w-full bg-[#FF7A00]/10 border border-[#FF7A00]/30 text-[#FF7A00] hover:bg-[#FF7A00]/20 px-4 py-3 rounded-lg font-bold text-sm transition flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Wallet
</button>
</div>
</div>
<!-- Add Wallet Modal -->
<div id="add-wallet-modal" class="modal-overlay" onclick="if(event.target===this)closeAddWalletModal()">
<div class="modal-box">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-white">Add Wallet</h3>
<button onclick="closeAddWalletModal()" class="text-gray-500 hover:text-white text-xl leading-none transition"></button>
</div>
<form id="add-wallet-form" onsubmit="return handleAddWallet(event)">
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Address</label>
<input id="input-address" type="text" placeholder="0x..." class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#FF7A00]/50 transition font-mono" required>
</div>
<div class="mb-6">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Nickname</label>
<input id="input-nickname" type="text" placeholder="Whale Wallet A" class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#FF7A00]/50 transition" required>
</div>
<div id="add-wallet-error" class="hidden mb-3 text-red-400 text-xs font-medium"></div>
<button type="submit" class="w-full bg-[#FF7A00] hover:bg-[#FF7A00]/90 text-white font-bold px-4 py-3 rounded-lg transition">Monitor Wallet</button>
</form>
</div>
</div>
<!-- Color Picker Popup -->
<div id="color-picker-overlay" class="color-picker-overlay" onclick="closeColorPicker()"></div>
<!-- Main Dashboard -->
<div class="max-w-7xl mx-auto">
<!-- Top Nav Bar -->
<div class="flex flex-col md:flex-row justify-between items-end mb-8 gap-4">
<div>
<h2 class="text-xs font-bold text-gray-500 mb-1 uppercase tracking-[0.2em]">Treasury Intelligence</h2>
<h1 class="text-3xl font-black text-white tracking-tight">cbBTC Acquisition <span class="text-[#FF7A00]">Core</span></h1>
</div>
<div class="flex flex-col items-end">
<div class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-2">Tracked Wallets</div>
<div class="flex gap-2 items-center">
<div id="wallet-pills" class="flex gap-2"></div>
<button onclick="openSidebar()" class="p-2 bg-[#090D14] border border-[#1A1F2C]/60 rounded-lg hover:bg-[#1A1F2C]/40 transition flex-shrink-0" title="Wallet Management">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Current Holdings</div>
<div class="text-xl font-bold text-white"><span id="net-held-val">0.000000</span> <span class="text-xs font-medium text-gray-500">BTC</span></div>
<div class="text-xs font-bold text-green-400 mt-1" id="current-usd-val">$Loading...</div>
</div>
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Total PnL</div>
<div class="text-xl font-bold" id="total-pnl-val">$--.--</div>
<div class="text-xs font-bold mt-1" id="pnl-percent-val">--.--%</div>
</div>
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Avg Buy Price</div>
<div class="text-xl font-bold text-white" id="avg-buy-val">$93,444</div>
<div class="text-xs font-medium text-gray-500 mt-1">Cost Basis</div>
</div>
<div class="bg-[#090D14] rounded-xl border border-[#1A1F2C]/60 p-4 shadow-xl">
<div class="text-[10px] font-bold text-gray-500 uppercase tracking-wider mb-1">Total Invested</div>
<div class="text-xl font-bold text-white" id="total-invested-val">$88,887.34</div>
<div class="text-xs font-medium text-gray-500 mt-1"><span id="tx-count-val">340</span> Transactions</div>
</div>
</div>
<!-- Charts Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div>
<div class="flex items-center space-x-2.5 mb-1">
<span class="text-sm font-medium text-gray-400">BTC Price</span>
<div class="flex items-center space-x-1.5 bg-green-500/10 border border-green-500/20 px-2 py-0.5 rounded-full">
<span class="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></span>
<span class="text-[10px] font-bold text-green-400 uppercase tracking-wide">Real-Time</span>
</div>
</div>
<h1 id="price-card-btc" class="text-3xl font-bold tracking-tight text-white">$Loading...</h1>
</div>
<div class="text-right">
<span id="return-card-btc" class="text-lg font-bold text-gray-400">--.--%</span>
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Since <span id="since-date">--</span></p>
</div>
</div>
<div class="w-full h-52 mt-4" id="chart-container-btc"></div>
</div>
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div>
<span class="text-sm font-medium text-gray-400">BTC Treasury Growth</span>
<h1 class="text-3xl font-bold tracking-tight text-white"><span id="cumul-btc-val">0.000000</span> <span class="text-sm text-gray-500">BTC</span></h1>
</div>
<div class="text-right">
<span class="text-lg font-bold" style="color: #FF7A00;">NET HELD</span>
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Cumulative Acquisition</p>
</div>
</div>
<div class="w-full h-52 mt-4" id="chart-container-cumul"></div>
</div>
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div>
<span class="text-sm font-medium text-gray-400">Avg BTC Acquired/Day</span>
<h1 class="text-3xl font-bold tracking-tight text-white"><span id="avg-sats-val">--</span> <span class="text-sm text-gray-500">sats</span></h1>
</div>
<div class="text-right">
<span class="text-lg font-bold" style="color: #FF7A00;">30D AVG</span>
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Daily Acquisition</p>
</div>
</div>
<div class="w-full h-52 mt-4" id="chart-container-sats"></div>
</div>
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 p-6 relative shadow-2xl transition-all duration-300 hover:scale-[1.03] hover:border-[#FF7A00] hover:shadow-[#FF7A00]/10 overflow-hidden">
<div class="flex justify-between items-start mb-4">
<div>
<span class="text-sm font-medium text-gray-400">Holdings Breakdown</span>
<h1 class="text-3xl font-bold tracking-tight text-white"><span id="breakdown-total-val">0.000000</span> <span class="text-sm text-gray-500">BTC</span></h1>
</div>
<div class="text-right">
<span class="text-lg font-bold" style="color: #58a6ff;">COMPOSITION</span>
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Direct + Aave</p>
</div>
</div>
<div class="w-full h-52 mt-4" id="chart-container-breakdown"></div>
</div>
</div>
<!-- Transaction Ledger -->
<div class="bg-[#090D14] rounded-2xl border border-[#1A1F2C]/60 shadow-2xl overflow-hidden">
<div class="p-6 border-b border-[#1A1F2C]/60">
<div class="ledger-header">
<div class="ledger-title-col">
<h3 class="text-sm font-bold text-gray-400 uppercase tracking-widest whitespace-nowrap">Transaction Ledger</h3>
</div>
<div class="flex-1 flex">
<div class="col-action">
<div class="filter-grid">
<label class="filter-item" title="Show BUY transactions">
<input type="checkbox" class="filter-checkbox buy" data-filter="BUY" checked>
</label>
<label class="filter-item" title="Show SELL transactions">
<input type="checkbox" class="filter-checkbox sell" data-filter="SELL" checked>
</label>
<label class="filter-item" title="Show AAVE_IN transactions (to Aave)">
<input type="checkbox" class="filter-checkbox aavein" data-filter="AAVE_IN" checked>
</label>
<label class="filter-item" title="Show AAVE_OUT transactions (from Aave)">
<input type="checkbox" class="filter-checkbox aaveout" data-filter="AAVE_OUT" checked>
</label>
</div>
</div>
<div class="col-wallet">
<div class="filter-grid" id="ledger-filter-wallets"></div>
</div>
</div>
<span class="text-[10px] font-medium text-gray-600 uppercase">Archive</span>
</div>
</div>
<div class="overflow-x-auto">
<table id="transaction-table" class="w-full text-left border-collapse">
<thead>
<tr class="bg-[#05070B]/50 text-[10px] font-bold text-gray-500 uppercase tracking-wider">
<th class="px-6 py-4 col-timestamp">Timestamp</th>
<th class="px-6 py-4 col-wallet">Wallet</th>
<th class="px-6 py-4 col-value text-right">Cold Storage</th>
<th class="px-6 py-4 col-value text-right">Collateral</th>
<th class="px-6 py-4 col-value text-right">Borrows</th>
<th class="px-6 py-4">Hash</th>
</tr>
</thead>
<tbody class="text-sm" id="table-body">
<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500 text-sm">Loading snapshots...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
/* ===================================================================
ESM WalletManager — loaded in <script type="module"> above.
Synchronize a fallback for classic <script> context so the
dashboard initialization below can use it.
=================================================================== */
(function(){
if (typeof window.WalletManager === 'undefined') {
/* If ESM module didn't fire (e.g. opened directly),
provide a lightweight localStorage helper. */
window.WalletManager = class {
constructor() { this._wallets = []; }
loadWallets() {
try {
const raw = localStorage.getItem('cbbtc_tracked_wallets');
this._wallets = raw ? JSON.parse(raw) : [];
if (!Array.isArray(this._wallets)) this._wallets = [];
this._wallets = this._wallets.filter(w => w && w.address && w.chain).map(w => ({ address: String(w.address), chain: String(w.chain).toLowerCase(), nickname: String(w.nickname||''), isVerified: false, signature: null, messageData: null }));
} catch(e) { this._wallets = []; }
return this._wallets;
}
getWallets() { return this._wallets.map(w => ({...w})); }
_persist() { try { localStorage.setItem('cbbtc_tracked_wallets', JSON.stringify(this._wallets)); } catch(e){} }
addWallet(address, chain, nickname='') {
const addr = String(address).trim();
const ch = String(chain).toLowerCase();
const nm = String(nickname).trim();
if (/^(0x)?[0-9a-fA-F]{40}$/i.test(addr) && (this._wallets.some(w => w.address===addr && w.chain===ch))) return { success: false, error: 'Wallet already tracked' };
if (!/^(0x)?[0-9a-fA-F]{40}$/i.test(addr)) return { success: false, error: 'Invalid address' };
const wallet = { address: addr, chain: ch, nickname: nm, isVerified: false, signature: null, messageData: null };
this._wallets.push(wallet);
this._persist();
return { success: true, wallet };
}
removeWallet(address, chain) {
const idx = this._wallets.findIndex(w => w.address === address && w.chain === chain);
if (idx === -1) return { success: false, error: 'Wallet not found' };
this._wallets.splice(idx, 1);
this._persist();
return { success: true, removedAddress: address };
}
findWallet(address, chain) { return this._wallets.find(w => w.address === address && w.chain === chain); }
renameWallet(address, chain, nickname) {
const w = this.findWallet(address, chain);
if (!w) return;
w.nickname = String(nickname).trim();
this._persist();
}
};
}
})();
/* ===================================================================
Constants
=================================================================== */
const orangeBrandColor = '#FF7A00';
const blueBrandColor = '#3b82f6';
const API_BASE = window.location.origin;
const WALLET_COLORS = ['#F7931A','#FF007F','#39FF14','#00FFFF','#CCFF00','#9D00FF','#FF0033','#00FFCC','#FF00FF','#007FFF','#DEFF0A','#FF5E00','#8A2BE2','#00FF66','#FF1493','#7B00FF'];
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
/* Embedded data for hardcoded wallets */
const walletCumulData = {"penguin": [[1732057200000, 0.0], [1732143600000, 0.0], [1732230000000, 0.0], [1732316400000, 0.0], [1732402800000, 0.0], [1732489200000, 0.0], [1732575600000, 0.0], [1732662000000, 0.0], [1732748400000, 0.0], [1732834800000, 0.0], [1732921200000, 0.00045671], [1733007600000, 0.00068314], [1733094000000, 0.00103581], [1733180400000, 0.00154653], [1733266800000, 0.00203502], [1733353200000, 0.00245059], [1733439600000, 0.0027401], [1733526000000, 0.0027401], [1733612400000, 0.0027401], [1733698800000, 0.0027401], [1733785200000, 0.00331277], [1733871600000, 0.00331277], [1733958000000, 0.00352929], [1734044400000, 0.00352929], [1734130800000, 0.00437782], [1734217200000, 0.00437782], [1734303600000, 0.00437782], [1734390000000, 0.00437782], [1734476400000, 0.00437782], [1734562800000, 0.00437782], [1734649200000, 0.00437782], [1734735600000, 0.00437782], [1734822000000, 0.00483383], [1734908400000, 0.00483383], [1734994800000, 0.00483383], [1735081200000, 0.00483383], [1735167600000, 0.00483383], [1735254000000, 0.00483383], [1735340400000, 0.00688418], [1735426800000, 0.00688418], [1735513200000, 0.00688418], [1735599600000, 0.00688418], [1735686000000, 0.00688418], [1735772400000, 0.00688418], [1735858800000, 0.00688418], [1735945200000, 0.00688418], [1736031600000, 0.00688418], [1736118000000, 0.00688418], [1736204400000, 0.00688418], [1736290800000, 0.00753678], [1736377200000, 0.00753678], [1736463600000, 0.00825444], [1736550000000, 0.00825444], [1736636400000, 0.00825444], [1736722800000, 0.0084866], [1736809200000, 0.0084866], [1736895600000, 0.0084866], [1736982000000, 0.0084866], [1737068400000, 0.0084866], [1737154800000, 0.0084866], [1737241200000, 0.0084866], [1737327600000, 0.0084866], [1737414000000, 0.0084866], [1737500400000, 0.0084866], [1737586800000, 0.0088232], [1737673200000, 0.0088232], [1737759600000, 0.0088232], [1737846000000, 0.0088232], [1737932400000, 0.00947181], [1738018800000, 0.00947181], [1738105200000, 0.00987744]], "nano": [[1732057200000, 0.0], [1732143600000, 0.0], [1732230000000, 0.0], [1732316400000, 0.0], [1732402800000, 0.0], [1732489200000, 0.0], [1732575600000, 0.0], [1732662000000, 0.0], [1732748400000, 0.0], [1732834800000, 0.0], [1732921200000, 0.00045671], [1733007600000, 0.00068314]]};
const walletsMetadata = {"nano": {"id": "nano", "address": "0x3837ea82a38daa985ee613e69f72adbe12d0aa50", "name": "Base Wallet", "color": "#58a6ff", "chain": "base"}, "penguin": {"id": "penguin", "address": "0x0c1a4a060e119f981412e323104d1c134d413dba", "name": "Base Wallet", "color": "#f7931a", "chain": "base"}, "cold": {"id": "cold", "address": "bc1qhwsm859uy7aec2h4rj3e00p2vg8nzy8je8duqz", "name": "BTC Cold Storage", "color": "#f7931a", "chain": "btc"}};
const walletCosts = {"nano": 57143.88957364997, "penguin": 28214.47038528, "cold": 3528.97661289};
const walletBuys = {"nano": 0.5830959900000001, "penguin": 0.29296042999999994, "cold": 0.07518186};
const dailySatsData = [{"ts": 1732057200000, "rolling_sats": 0, "btc_val": 0, "price": 94287, "days_since": -10, "cumul_btc": 0}, {"ts": 1732143600000, "rolling_sats": 0, "btc_val": 0, "price": 98317, "days_since": -9, "cumul_btc": 0}, {"ts": 1732230000000, "rolling_sats": 0, "btc_val": 0, "price": 98892, "days_since": -8, "cumul_btc": 0}, {"ts": 1732316400000, "rolling_sats": 0, "btc_val": 0, "price": 97672, "days_since": -7, "cumul_btc": 0}, {"ts": 1732402800000, "rolling_sats": 0, "btc_val": 0, "price": 97900, "days_since": -6, "cumul_btc": 0}, {"ts": 1732489200000, "rolling_sats": 0, "btc_val": 0, "price": 93010, "days_since": -5, "cumul_btc": 0}, {"ts": 1732575600000, "rolling_sats": 0, "btc_val": 0, "price": 91965, "days_since": -4, "cumul_btc": 0}, {"ts": 1732662000000, "rolling_sats": 0, "btc_val": 0, "price": 95863, "days_since": -3, "cumul_btc": 0}, {"ts": 1732748400000, "rolling_sats": 0, "btc_val": 0, "price": 95644, "days_since": -2, "cumul_btc": 0}, {"ts": 1732834800000, "rolling_sats": 0, "btc_val": 0, "price": 97460, "days_since": -1, "cumul_btc": 0}, {"ts": 1732921200000, "rolling_sats": 4152, "btc_val": 0.00004152, "price": 96408, "days_since": 0, "cumul_btc": 0.00045671}, {"ts": 1733007600000, "rolling_sats": 5693, "btc_val": 0.00005693, "price": 97185, "days_since": 1, "cumul_btc": 0.00068314}, {"ts": 1733094000000, "rolling_sats": 7968, "btc_val": 0.00007968, "price": 95841, "days_since": 2, "cumul_btc": 0.00103581}, {"ts": 1733180400000, "rolling_sats": 11047, "btc_val": 0.00011047, "price": 95850, "days_since": 3, "cumul_btc": 0.00154653}, {"ts": 1733266800000, "rolling_sats": 13567, "btc_val": 0.00013567, "price": 98587, "days_since": 4, "cumul_btc": 0.00203502}, {"ts": 1733353200000, "rolling_sats": 15316, "btc_val": 0.00015316, "price": 96946, "days_since": 5, "cumul_btc": 0.00245059}, {"ts": 1733439600000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 96537, "days_since": 6, "cumul_btc": 0.0027401}, {"ts": 1733526000000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 96201, "days_since": 7, "cumul_btc": 0.0027401}, {"ts": 1733612400000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 95811, "days_since": 8, "cumul_btc": 0.0027401}, {"ts": 1733698800000, "rolling_sats": 16118, "btc_val": 0.00016118, "price": 97183, "days_since": 9, "cumul_btc": 0.0027401}, {"ts": 1733785200000, "rolling_sats": 19612, "btc_val": 0.00019612, "price": 96685, "days_since": 10, "cumul_btc": 0.00331277}, {"ts": 1733871600000, "rolling_sats": 19612, "btc_val": 0.00019612, "price": 97491, "days_since": 11, "cumul_btc": 0.00331277}, {"ts": 1733958000000, "rolling_sats": 20414, "btc_val": 0.00020414, "price": 96537, "days_since": 12, "cumul_btc": 0.00352929}, {"ts": 1734044400000, "rolling_sats": 20414, "btc_val": 0.00020414, "price": 96685, "days_since": 13, "cumul_btc": 0.00352929}, {"ts": 1734130800000, "rolling_sats": 22742, "btc_val": 0.00022742, "price": 97163, "days_since": 14, "cumul_btc": 0.00437782}, {"ts": 1734217200000, "rolling_sats": 22742, "btc_val": 0.00022742, "price": 97000, "days_since": 15, "cumul_btc": 0.00437782}];
const aaveCumulData = [[1732057200000, 0], [1732143600000, 0], [1732230000000, 0], [1732316400000, 0], [1732402800000, 0], [1732489200000, 0], [1732575600000, 0], [1732662000000, 0], [1732748400000, 0], [1732834800000, 0], [1732921200000, 0], [1733007600000, 0], [1733094000000, 0], [1733180400000, 0], [1733266800000, 0], [1733353200000, 0]];
const directCumulData = [[1732057200000, 0], [1732143600000, 0], [1732230000000, 0], [1732316400000, 0], [1732402800000, 0], [1732489200000, 0], [1732575600000, 0], [1732662000000, 0], [1732748400000, 0], [1732834800000, 0], [1732921200000, 0.00045671], [1733007600000, 0.00068314]];
const totalCumulData = [[1732057200000, 0], [1732143600000, 0], [1732230000000, 0], [1732316400000, 0], [1732402800000, 0], [1732489200000, 0], [1732575600000, 0], [1732662000000, 0], [1732748400000, 0], [1732834800000, 0], [1732921200000, 0.00045671], [1733007600000, 0.00068314]];
/* ===================================================================
App State
=================================================================== */
const wm = new WalletManager();
wm.loadWallets();
let currentAggregatedSeries = [];
let currentNetHeld = 0;
let currentBuyCost = 0;
let currentBuyAmount = 0;
let btcPriceData = [];
let currentCutoffMs;
let tickIntervalId;
/* Aave data */
let aavePriceMap = {};
let allAaveSnapshots = [];
let lastFetchMs = 0;
let todayBtcPrice = null;
/* Sync state per address: 'synced' | 'syncing' | 'pending' | undefined */
const walletSyncState = {};
/* Per-address snapshot data, keyed by address */
const addressSnapshots = {};
/* Which embedded wallet IDs each tracked address maps to */
function findEmbeddedId(address) {
for (const [id, meta] of Object.entries(walletsMetadata)) {
if (meta.address.toLowerCase() === address.toLowerCase()) return id;
}
return null;
}
/* Assigned color index for dynamic wallets */
let _colorIdx = 0;
function getColorForWallet(address) {
const c = localStorage.getItem('cbbtc_color_' + address);
if (c) return c;
const embedded = findEmbeddedId(address);
if (embedded && walletsMetadata[embedded].color) return walletsMetadata[embedded].color;
const color = WALLET_COLORS[_colorIdx % WALLET_COLORS.length];
_colorIdx++;
localStorage.setItem('cbbtc_color_' + address, color);
return color;
}
function setColorForWallet(address, color) {
localStorage.setItem('cbbtc_color_' + address, color);
renderAll();
}
function cycleWalletColor(address) {
const next = getNextColor(address);
setColorForWallet(address, next);
}
function getNextColor(address) {
const current = getColorForWallet(address);
const idx = WALLET_COLORS.indexOf(current);
const nextIdx = (idx + 1) % WALLET_COLORS.length;
return WALLET_COLORS[nextIdx];
}
/* Color Picker */
let _colorPickerTargetAddr = null;
function openColorPicker(event, address) {
event.stopPropagation();
_colorPickerTargetAddr = address;
const overlay = document.getElementById('color-picker-overlay');
const popup = document.getElementById('color-picker-popup');
if (popup) popup.remove();
const newPopup = document.createElement('div');
newPopup.id = 'color-picker-popup';
newPopup.className = 'color-picker-popup';
newPopup.style.left = event.clientX + 'px';
newPopup.style.top = event.clientY + 'px';
const currentColor = getColorForWallet(address);
WALLET_COLORS.forEach((c, i) => {
const swatch = document.createElement('div');
swatch.className = 'color-picker-swatch' + (c.toLowerCase() === currentColor.toLowerCase() ? ' selected' : '');
swatch.style.setProperty('--swatch-color', c);
swatch.style.background = c;
swatch.addEventListener('click', (e) => {
e.stopPropagation();
selectColor(c);
closeColorPicker();
});
newPopup.appendChild(swatch);
});
document.body.appendChild(newPopup);
const rect = newPopup.getBoundingClientRect();
if (rect.right > window.innerWidth) newPopup.style.left = (event.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight) newPopup.style.top = (event.clientY - rect.height) + 'px';
overlay.classList.add('open');
}
function closeColorPicker() {
document.getElementById('color-picker-overlay').classList.remove('open');
const popup = document.getElementById('color-picker-popup');
if (popup) popup.remove();
_colorPickerTargetAddr = null;
}
function selectColor(color) {
if (_colorPickerTargetAddr) {
setColorForWallet(_colorPickerTargetAddr, color);
}
}
/* Wallet filter state — stores UNCHECKED addresses. Empty = all checked. */
function getWalletFilter() {
return JSON.parse(localStorage.getItem('cbbtc_ledger_wallets') || '[]');
}
function isWalletInLedger(address) {
return !getWalletFilter().includes(address); /* unchecked not in list => checked */
}
function setWalletChecked(address, checked) {
let unchecked = getWalletFilter();
if (!checked) {
if (!unchecked.includes(address)) unchecked.push(address);
} else {
const idx = unchecked.indexOf(address);
if (idx >= 0) unchecked.splice(idx, 1);
}
if (unchecked.length === 0) {
localStorage.removeItem('cbbtc_ledger_wallets');
} else {
localStorage.setItem('cbbtc_ledger_wallets', JSON.stringify(unchecked));
}
syncAllWalletCheckboxes();
updateDashboard();
}
function toggleWalletFilter(address) {
setWalletChecked(address, !isWalletInLedger(address));
}
function syncWalletCheckboxes(address, checked) {
document.querySelectorAll(`.wallet-filter-toggle[data-address="${address}"]`).forEach(cb => {
cb.checked = checked;
});
}
function syncAllWalletCheckboxes() {
wm.getWallets().forEach(w => {
const checked = isWalletInLedger(w.address);
document.querySelectorAll(`.wallet-filter-toggle[data-address="${w.address}"]`).forEach(cb => {
cb.checked = checked;
});
});
}
function renderAll() {
renderSidebar();
renderWalletPills();
renderLedgerFilterWallets();
updateDashboard();
}
/* ===================================================================
Sidebar
=================================================================== */
function openSidebar() {
document.getElementById('sidebar-overlay').classList.add('open');
document.getElementById('sidebar-panel').classList.add('open');
renderSidebar();
}
function closeSidebar() {
document.getElementById('sidebar-overlay').classList.remove('open');
document.getElementById('sidebar-panel').classList.remove('open');
}
function renderSidebar() {
const wallets = wm.getWallets();
const container = document.getElementById('sidebar-wallet-list');
if (wallets.length === 0) {
container.innerHTML = '<div class="text-gray-600 text-xs text-center py-6">No wallets tracked yet.<br>Add one to get started.</div>';
return;
}
let html = '';
wallets.forEach((w, i) => {
const color = getColorForWallet(w.address);
const shortAddr = w.address.slice(0, 6) + '...' + w.address.slice(-4);
const syncState = walletSyncState[w.address];
const syncBadge = syncState === 'syncing' || syncState === 'pending'
? `<span class="text-yellow-400 text-[10px] ml-2">⏳ Syncing</span>`
: '';
const nick = w.nickname || shortAddr;
html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' +
'<div class="flex items-center gap-2.5 min-w-0 flex-1">' +
'<div onclick="openColorPicker(event, \'' + w.address + '\')" class="wallet-color-dot" style="--wallet-color:' + color + ';background:' + color + ';" title="Click to change color"></div>' +
'<div class="min-w-0 flex-1">' +
'<input class="rename-nick-input bg-transparent text-sm font-medium text-white truncate border border-transparent focus:border-[#FF7A00]/50 rounded px-1 outline-none transition block w-full" value="' + nick + '" data-addr="' + w.address + '" data-chain="' + w.chain + '" onblur="handleRenameNickname(this)" onkeydown="if(event.key===\'Enter\')this.blur()">' +
'<div class="text-[10px] text-gray-500 font-mono truncate">' + shortAddr + '</div>' +
'</div>' +
'</div>' +
'<div class="flex items-center gap-1 flex-shrink-0">' +
syncBadge +
'<button onclick="handleDeleteWallet(\'' + w.address + '\',\'' + w.chain + '\',' + i + ')" class="text-gray-600 hover:text-red-400 transition" title="Remove wallet">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
'<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>' +
'</svg>' +
'</button>' +
'</div>' +
'</div>';
});
container.innerHTML = html;
}
function handleRenameNickname(input) {
var addr = input.dataset.addr;
var chain = input.dataset.chain;
var nick = input.value.trim();
wm.renameWallet(addr, chain, nick);
renderWalletPills();
}
/* ===================================================================
Add Wallet Modal
=================================================================== */
function openAddWalletModal() {
document.getElementById('add-wallet-modal').classList.add('open');
document.getElementById('add-wallet-error').classList.add('hidden');
document.getElementById('input-address').value = '';
document.getElementById('input-nickname').value = '';
}
function closeAddWalletModal() {
document.getElementById('add-wallet-modal').classList.remove('open');
}
async function handleAddWallet(e) {
e.preventDefault();
const address = document.getElementById('input-address').value.trim();
const nickname = document.getElementById('input-nickname').value.trim();
const errEl = document.getElementById('add-wallet-error');
if (!address) { errEl.textContent = 'Address is required'; errEl.classList.remove('hidden'); return false; }
if (!/^(0x)?[0-9a-fA-F]{40}$/i.test(address)) { errEl.textContent = 'Invalid EVM address format'; errEl.classList.remove('hidden'); return false; }
/* Register in WalletManager */
const addResult = wm.addWallet(address, 'base', nickname);
if (!addResult.success) { errEl.textContent = addResult.error; errEl.classList.remove('hidden'); return false; }
/* POST to FastAPI backend to start monitoring */
try {
const resp = await fetch('http://localhost:8000/api/v1/portfolio/monitor', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address: address, chain: 'base', chain_id: 8453 })
});
if (!resp.ok) {
errEl.textContent = `Backend returned ${resp.status}. Wallet saved locally.`;
errEl.classList.remove('hidden');
}
} catch (err) {
errEl.textContent = 'Backend unavailable. Wallet saved locally.';
errEl.classList.remove('hidden');
}
/* Mark as syncing initially */
walletSyncState[address] = 'syncing';
closeAddWalletModal();
renderSidebar();
renderWalletPills();
renderLedgerFilterWallets();
/* Kick off a fetch for this new wallet */
fetchWalletAaveData(address);
updateDashboard();
return false;
}
/* ===================================================================
Delete Wallet
=================================================================== */
function handleDeleteWallet(address, chain, idx) {
wm.removeWallet(address, chain);
delete walletSyncState[address];
delete addressSnapshots[address];
/* Remove from unchecked list */
let unchecked = getWalletFilter();
const ui = unchecked.indexOf(address);
if (ui >= 0) unchecked.splice(ui, 1);
if (unchecked.length === 0) {
localStorage.removeItem('cbbtc_ledger_wallets');
} else {
localStorage.setItem('cbbtc_ledger_wallets', JSON.stringify(unchecked));
}
renderAll();
}
/* ===================================================================
Wallet Pills (main dashboard selection)
=================================================================== */
function renderWalletPills() {
const wallets = wm.getWallets();
const container = document.getElementById('wallet-pills');
if (wallets.length === 0) {
container.innerHTML = '<span class="text-xs text-gray-600 italic">No wallets added. Open the sidebar to add one.</span>';
return;
}
let html = '';
wallets.forEach((w) => {
const color = getColorForWallet(w.address);
const syncState = walletSyncState[w.address];
const isSyncing = syncState === 'syncing' || syncState === 'pending';
const syncIndicator = isSyncing
? ' <span class="text-[9px] text-yellow-400 animate-pulse">⏳</span>'
: '';
const nick = w.nickname || 'Wallet';
const isActive = isWalletInLedger(w.address);
html += '<label class="pill-btn flex items-center gap-2 bg-[#090D14] border border-[#1A1F2C]/60 px-3 py-1.5 rounded-lg transition-all cursor-pointer select-none" data-address="' + w.address + '">' +
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-address="' + w.address + '" ' + (isActive ? 'checked' : '') + ' onchange="event.stopPropagation();toggleWalletPill(\'' + w.address + '\')">' +
'<div class="flex flex-col">' +
'<span class="text-[10px] font-bold text-gray-400 uppercase tracking-wider leading-tight">' + nick + syncIndicator + '</span>' +
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
'</div>' +
'</label>';
});
container.innerHTML = html;
}
function toggleWalletPill(address) {
toggleWalletFilter(address);
renderLedgerFilterWallets();
}
function getSelectedAddresses() {
const unchecked = getWalletFilter();
const wallets = wm.getWallets();
if (unchecked.length === 0) return wallets.map(w => w.address);
return wallets.filter(w => !unchecked.includes(w.address)).map(w => w.address);
}
function syncWalletCheckboxes(address, checked) {
document.querySelectorAll(`.wallet-filter-toggle[data-address="${address}"]`).forEach(cb => {
cb.checked = checked;
});
}
/* ===================================================================
Ledger filter wallet checkboxes
=================================================================== */
function renderLedgerFilterWallets() {
const wallets = wm.getWallets();
const container = document.getElementById('ledger-filter-wallets');
let html = '';
wallets.forEach(w => {
const color = getColorForWallet(w.address);
const checked = isWalletInLedger(w.address);
html += `<label class="filter-item" title="Show ${w.nickname || w.address}">
<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ${color};" data-address="${w.address}" ${checked ? 'checked' : ''}>
</label>`;
});
container.innerHTML = html;
container.querySelectorAll('.wallet-filter-toggle').forEach(cb => {
cb.addEventListener('change', () => {
setWalletChecked(cb.dataset.address, cb.checked);
});
});
}
/* ===================================================================
Dynamic Data Fetching (Promise.all aggregation)
=================================================================== */
async function fetchWalletAaveData(address) {
const endpoint = `/api/v1/portfolio/${address}/avve`;
try {
const resp = await fetch(`${API_BASE}${endpoint}`);
if (resp.status === 400) {
const body = await resp.json().catch(() => ({}));
const details = (body?.detail || body?.details || '').toString().toUpperCase();
if (details.includes('PENDING') || details.includes('SYNCING')) {
walletSyncState[address] = 'syncing';
renderWalletPills();
return;
}
}
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const events = await resp.json();
walletSyncState[address] = 'synced';
addressSnapshots[address] = snapshotsToDaily(events);
renderWalletPills();
} catch (err) {
console.error(`Failed to fetch data for ${address}:`, err);
walletSyncState[address] = 'pending';
renderWalletPills();
}
}
async function fetchSelectedWalletsData() {
const selectedAddr = getSelectedAddresses();
if (selectedAddr.length === 0) return;
/* Only fetch for wallets that are synced or syncing */
const promises = selectedAddr.map(async (addr) => {
const sync = walletSyncState[addr];
if (sync === 'syncing') return null; /* Will retry later */
if (sync === 'pending') return null;
if (sync === 'synced' && addressSnapshots[addr]) return null; /* Cached */
await fetchWalletAaveData(addr);
});
await Promise.all(promises);
}
/* ===================================================================
Chart Helpers
=================================================================== */
const penguinAddress = '0x0c1a4a060e119f981412e323104d1c134d413dba';
function getTokenAmount(raw, symbol) {
const dec = TOKENS[symbol] ? TOKENS[symbol].decimals : 18;
return parseFloat(raw || '0') / Math.pow(10, dec);
}
function priceForToken(symbol, dateStr) {
const today = new Date().toISOString().slice(0, 10);
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
if (dateStr === today && ps === 'BTC' && todayBtcPrice) return todayBtcPrice;
if (!aavePriceMap[ps]) return null;
if (aavePriceMap[ps][dateStr]) return parseFloat(aavePriceMap[ps][dateStr]);
const arr = Object.keys(aavePriceMap[ps]).sort();
for (let i = arr.length - 1; i >= 0; i--) {
if (arr[i] <= dateStr) return parseFloat(aavePriceMap[ps][arr[i]]);
}
return null;
}
async function fetchPrices(symbols, oldestDateMs) {
try {
const today = new Date();
const oldest = new Date(oldestDateMs);
const range = Math.max(2, Math.ceil((today - oldest) / 86400000) + 3);
const priceSymbols = [...new Set(symbols.map(s => TOKENS[s] ? TOKENS[s].priceSymbol : s))];
const promises = priceSymbols.map(ps => fetch(`${API_BASE}/api/v1/prices/${ps}/history?range=${range}`).then(r => r.json()));
const results = await Promise.all(promises);
priceSymbols.forEach((ps, i) => {
aavePriceMap[ps] = {};
results[i].forEach(item => { aavePriceMap[ps][item.date] = item.close; });
});
} catch (err) {
console.error("Failed to load prices:", err);
}
}
async function refreshPrices() {
try {
const priceSymbols = Object.values(TOKENS).map(t => t.priceSymbol);
const uniqueSymbols = [...new Set(priceSymbols)];
const promises = uniqueSymbols.map(ps => fetch(`${API_BASE}/api/v1/prices/${ps}/history?range=2`).then(r => r.json()));
const results = await Promise.all(promises);
uniqueSymbols.forEach((ps, i) => {
if (!aavePriceMap[ps]) aavePriceMap[ps] = {};
results[i].forEach(item => { aavePriceMap[ps][item.date] = item.close; });
});
if (btcPriceData.length > 0) {
todayBtcPrice = btcPriceData[btcPriceData.length - 1][1];
}
} catch (err) {
console.error("Failed to refresh prices:", err);
}
}
function snapshotsToDaily(events) {
const byDay = {};
events.forEach(evt => {
const ts = new Date(evt.block_timestamp).getTime();
const dateStr = evt.block_timestamp.slice(0, 10);
if (!byDay[dateStr] || ts > byDay[dateStr].block_ts) {
byDay[dateStr] = {...evt, block_ts: ts};
}
});
return Object.values(byDay).sort((a, b) => b.block_ts - a.block_ts);
}
/* ===================================================================
Dashboard Update
=================================================================== */
function getOldestTransactionDate() {
const selectedAddr = getSelectedAddresses();
if (selectedAddr.length === 0) return Date.now() - 365 * 86400000;
let oldestTs = Infinity;
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => {
const sTs = new Date(snap.block_timestamp).getTime();
if (sTs < oldestTs) oldestTs = sTs;
});
});
if (selectedAddr.map(a => a.toLowerCase()).includes(penguinAddress.toLowerCase())) {
allAaveSnapshots.forEach(snap => {
const sTs = new Date(snap.block_timestamp).getTime();
if (sTs < oldestTs) oldestTs = sTs;
});
}
return oldestTs === Infinity ? Date.now() - 365 * 86400000 : oldestTs;
}
function calculateAggregatedSeries() {
const selectedAddr = getSelectedAddresses();
const activeWalletIds = selectedAddr.map(a => findEmbeddedId(a)).filter(Boolean);
const uniqueIds = [...new Set(activeWalletIds)];
currentBuyCost = 0;
currentBuyAmount = 0;
if (uniqueIds.length === 0) {
currentAggregatedSeries = [];
currentNetHeld = 0;
return;
}
const firstWid = uniqueIds[0];
const baseSeries = walletCumulData[firstWid] || [];
const result = baseSeries.map(point => [point[0], 0]);
uniqueIds.forEach(wid => {
const series = walletCumulData[wid];
if (series) {
series.forEach((point, i) => {
if (result[i]) result[i][1] += point[1];
});
}
currentBuyCost += walletCosts[wid] || 0;
currentBuyAmount += walletBuys[wid] || 0;
});
currentAggregatedSeries = result;
}
function calculateCurrentHoldings() {
const selectedAddr = getSelectedAddresses();
currentNetHeld = 0;
currentBuyCost = 0;
currentBuyAmount = 0;
if (selectedAddr.length === 0) return;
const hasPenguinSnapshots = selectedAddr.map(a => a.toLowerCase()).includes(penguinAddress.toLowerCase()) && allAaveSnapshots.length > 0;
let hasSnapshotData = false;
if (hasPenguinSnapshots) {
const latest = allAaveSnapshots[0];
const cold = getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
const collat = getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
currentNetHeld += cold + collat;
hasSnapshotData = true;
}
/* Also aggregate from dynamic snapshots */
selectedAddr.forEach(addr => {
const embeddedId = findEmbeddedId(addr);
if (addr.toLowerCase() === penguinAddress.toLowerCase() && hasSnapshotData) return;
const snaps = addressSnapshots[addr];
if (snaps && snaps.length > 0) {
const latest = snaps[0];
currentNetHeld += getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
currentNetHeld += getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
}
if (embeddedId && !hasSnapshotData) {
const series = walletCumulData[embeddedId];
if (series && series.length > 0) currentNetHeld += series[series.length - 1][1];
currentBuyCost += walletCosts[embeddedId] || 0;
currentBuyAmount += walletBuys[embeddedId] || 0;
}
});
}
function updateDashboard() {
calculateAggregatedSeries();
calculateCurrentHoldings();
if (window.cumulChart) {
window.cumulChart.updateSeries([{ data: currentAggregatedSeries }]);
}
document.getElementById('net-held-val').innerText = currentNetHeld.toFixed(6);
document.getElementById('cumul-btc-val').innerText = currentNetHeld.toFixed(6);
document.getElementById('breakdown-total-val').innerText = currentNetHeld.toFixed(6);
const avgBuy = currentBuyAmount > 0 ? currentBuyCost / currentBuyAmount : 0;
document.getElementById('avg-buy-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(avgBuy);
document.getElementById('total-invested-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentBuyCost);
if (btcPriceData.length > 0) {
updateBtcUI(btcPriceData[btcPriceData.length - 1][1], 0);
}
/* Filter transaction table */
const selectedAddr = getSelectedAddresses();
const activeTypes = Array.from(document.querySelectorAll('.filter-checkbox:checked')).map(cb => cb.dataset.filter);
const ledgerWallets = Array.from(document.querySelectorAll('.wallet-filter-toggle:checked')).map(cb => cb.dataset.address);
let visibleCount = 0;
document.querySelectorAll('#transaction-table tbody tr').forEach(row => {
const wAddr = row.dataset.wallet;
const actionBadge = row.querySelector('.badge');
let typeMatch = true;
if (actionBadge) {
const actionType = actionBadge.dataset.type;
typeMatch = activeTypes.includes(actionType);
}
const walletMatch = ledgerWallets.includes(wAddr);
const isVisible = walletMatch && typeMatch;
row.classList.toggle('hidden', !isVisible);
if (isVisible) visibleCount++;
});
document.getElementById('tx-count-val').innerText = visibleCount;
const newCutoff = getOldestTransactionDate();
if (newCutoff !== currentCutoffMs) {
if (btcPriceData.length > 0) refreshAllCharts(newCutoff);
}
}
/* ===================================================================
Render Transaction Table
=================================================================== */
function fmtUsd(v) {
if (v == null || isNaN(v)) return '—';
if (v >= 1000) return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v);
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(v);
}
function tokenTooltipHTML(items) {
let html = '';
let total = 0;
items.forEach(it => {
if (it.usd >= 5) {
html += `<div class="tooltip-row"><span class="text-gray-400">${it.symbol}</span><span class="text-white">${it.amountStr} × ${it.priceStr}</span><span class="text-gray-300">${it.usdStr}</span></div>`;
total += it.usd;
}
});
html += `<div class="tooltip-row total"><span class="text-gray-400">Total</span><span class="text-orange-400">${new Intl.NumberFormat('en-US',{style:'currency','currency':'USD','maximumFractionDigits':0}).format(total)}</span></div>`;
return html;
}
function renderCombinedTable() {
const walletColorMap = {};
const walletNickMap = {};
const wallets = wm.getWallets();
wallets.forEach(w => {
walletColorMap[w.address] = getColorForWallet(w.address);
walletNickMap[w.address] = w.nickname || 'Wallet';
});
walletsMetadata && Object.values(walletsMetadata).forEach(m => {
walletColorMap[m.address] = m.color || '#f7931a';
walletNickMap[m.address] = m.name;
});
/* Build unified snapshot list from all sources */
const unified = [];
/* Penguin (embedded) snapshots */
if (allAaveSnapshots.length > 0) {
allAaveSnapshots.forEach(snap => {
unified.push({ ...snap, _walletAddress: penguinAddress });
});
}
/* Dynamic snapshots */
for (const [addr, snaps] of Object.entries(addressSnapshots)) {
(snaps || []).forEach(snap => {
unified.push({ ...snap, _walletAddress: addr });
});
}
unified.sort((a, b) => new Date(b.block_timestamp) - new Date(a.block_timestamp));
const tbody = document.querySelector('#table-body');
if (unified.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500 text-sm">No snapshots found. Add wallets in the sidebar.</td></tr>';
updateDashboard();
return;
}
let html = '';
unified.forEach(snap => {
const ts = new Date(snap.block_timestamp);
const dateStr = snap.block_timestamp.slice(0, 10);
const timeStr = ts.toTimeString().slice(0, 5);
const shortHash = snap.tx_hash ? snap.tx_hash.slice(0, 10) + '...' : '—';
const hashLink = snap.tx_hash ? `https://basescan.org/tx/${snap.tx_hash}` : '#';
const walletAddr = snap._walletAddress;
const color = walletColorMap[walletAddr] || '#f7931a';
const nick = walletNickMap[walletAddr] || walletAddr.slice(0, 8);
/* Cold storage */
const coldItems = [];
let coldTotal = 0;
Object.entries(snap.wallet || {}).forEach(([sym, raw]) => {
const amt = getTokenAmount(raw, sym);
if (amt <= 0) return;
const price = priceForToken(sym, dateStr) || 0;
const usd = amt * price;
coldTotal += usd;
coldItems.push({
symbol: sym, amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2),
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0',
usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
});
});
const collateralItems = [];
let collateralTotal = 0;
Object.entries(snap.collateral || {}).forEach(([sym, raw]) => {
const amt = getTokenAmount(raw, sym);
if (amt <= 0) return;
const price = priceForToken(sym, dateStr) || 0;
const usd = amt * price;
collateralTotal += usd;
collateralItems.push({
symbol: sym, amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(4),
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0',
usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
});
});
const borrowItems = [];
let borrowTotal = 0;
Object.entries(snap.debt || {}).forEach(([sym, raw]) => {
const amt = getTokenAmount(raw, sym);
if (amt <= 0) return;
const price = priceForToken(sym, dateStr) || 0;
const usd = amt * price;
borrowTotal += usd;
borrowItems.push({
symbol: sym, amountStr: amt < 1 ? amt.toFixed(6).replace(/0+$/, '') : amt.toFixed(2),
priceStr: price > 0 ? new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',maximumFractionDigits:0}).format(price) : '$0',
usdStr: new Intl.NumberFormat('en-US',{style:'currency','currency':'USD',minimumFractionDigits:0,maximumFractionDigits:0}).format(usd), usd
});
});
/* Determine action type for filtering */
let actionType = 'AAVE_IN';
if (borrowTotal > 0 && (coldTotal === 0 || collateralTotal === 0)) actionType = 'AAVE_OUT';
else if (collateralTotal > 0) actionType = 'AAVE_IN';
const badgeClass = actionType === 'AAVE_IN' ? 'badge-aavein' : 'badge-aaveout';
const coldTooltip = tokenTooltipHTML(coldItems);
const collTooltip = tokenTooltipHTML(collateralItems);
const borrowTooltip = tokenTooltipHTML(borrowItems);
html += `<tr class="border-b border-[#1A1F2C]/30 hover:bg-[#1A1F2C]/20 transition-colors" data-wallet="${walletAddr}">
<td class="px-6 py-4 col-timestamp font-medium text-gray-400 whitespace-nowrap">${dateStr}<br><span class="text-[10px] text-gray-600">${timeStr}</span></td>
<td class="px-6 py-4 col-wallet">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-sm" style="background-color: ${color}; box-shadow: 0 0 8px ${color}66;"></div>
<span class="text-xs font-medium text-gray-300">${nick}</span>
</div>
</td>
<td class="px-6 py-4 col-value text-right tooltip-cell">
<span class="font-mono font-bold text-white">${fmtUsd(coldTotal)}</span>
${coldItems.length > 0 ? `<div class="tooltip-box">${coldTooltip}</div>` : ''}
</td>
<td class="px-6 py-4 col-value text-right tooltip-cell">
<span class="font-mono font-bold text-green-400">${fmtUsd(collateralTotal)}</span>
${collateralItems.length > 0 ? `<div class="tooltip-box">${collTooltip}</div>` : ''}
</td>
<td class="px-6 py-4 col-value text-right tooltip-cell">
<span class="font-mono font-bold ${borrowTotal > 0 ? 'text-red-400' : 'text-gray-600'}">${fmtUsd(borrowTotal)}</span>
${borrowItems.length > 0 ? `<div class="tooltip-box">${borrowTooltip}</div>` : ''}
</td>
<td class="px-6 py-4"><a href="${hashLink}" target="_blank" class="text-[#FF7A00] opacity-60 hover:opacity-100 transition-opacity font-mono text-xs">${shortHash}</a></td>
</tr>`;
});
tbody.innerHTML = html;
updateDashboard();
}
/* ===================================================================
Fetch Aave Snapshots (for embedded wallets)
=================================================================== */
async function fetchAaveSnapshots() {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${penguinAddress}/base/aave`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const events = await resp.json();
const oldestTs = new Date(events[0].block_timestamp).getTime();
await fetchPrices(Object.keys(TOKENS), oldestTs);
allAaveSnapshots = snapshotsToDaily(events);
addressSnapshots[penguinAddress] = allAaveSnapshots;
walletSyncState[penguinAddress] = 'synced';
} catch (err) {
console.error("Failed to load aave data:", err);
}
}
/* ===================================================================
Chart Setup
=================================================================== */
function getBaseChartOptions(chartId, dataset, color, isBTC) {
return {
chart: { id: chartId, type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
series: [{ name: isBTC ? 'BTC Price' : 'Net Held', data: dataset }],
dataLabels: { enabled: false },
colors: [color],
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [{ offset: 0, color: color, opacity: 0.16 }, { offset: 100, color: color, opacity: 0 }] } },
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
markers: { size: 0, hover: { size: 5 } },
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false } },
yaxis: { opposite: true, min: (min) => isBTC ? min * 0.98 : min * 0.95, max: (max) => isBTC ? max * 1.02 : max * 1.05, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: function(val) { if (isBTC) return (val / 1000).toFixed(0) + 'k'; return val.toFixed(4); } }, axisBorder: { show: false }, axisTicks: { show: false } },
tooltip: { enabled: true, theme: 'dark', shared: false, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const rawData = w.config.series[seriesIndex].data[dataPointIndex]; if (!rawData) return ''; const date = new Date(rawData[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const valFormatted = isBTC ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(rawData[1]) : rawData[1].toFixed(6) + ' BTC'; return '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div><div class="text-white text-sm font-bold mt-0.5">' + valFormatted + '</div></div>'; } },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
};
}
function refreshAllCharts(cutoffMs) {
currentCutoffMs = cutoffMs;
document.getElementById('since-date').innerText = new Intl.DateTimeFormat('en-US', { month: 'short', year: 'numeric' }).format(new Date(cutoffMs));
if (tickIntervalId) clearInterval(tickIntervalId);
if (window.btcChart) window.btcChart.destroy();
if (window.cumulChart) window.cumulChart.destroy();
if (window.satsChart) window.satsChart.destroy();
if (window.breakdownChart) window.breakdownChart.destroy();
const filtered = btcPriceData.filter(d => d[0] >= cutoffMs);
if (filtered.length > 0) setupBtcCard(filtered);
setupCumulCard(cutoffMs);
setupSatsCard(cutoffMs);
setupBreakdownCard(cutoffMs);
startTickUpdates(btcPriceData.filter(d => d[0] >= cutoffMs));
}
function setupBtcCard(data) {
const initialPrice = data[0][1];
const currentPrice = data[data.length - 1][1];
const percentChange = ((currentPrice - initialPrice) / initialPrice) * 100;
updateBtcUI(currentPrice, percentChange);
const options = getBaseChartOptions('instance-btc', data, orangeBrandColor, true);
const chart = new ApexCharts(document.querySelector("#chart-container-btc"), options);
chart.render();
window.btcChart = chart;
}
function updateBtcUI(price, percent) {
document.getElementById('price-card-btc').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price);
if (percent !== 0) {
const retEl = document.getElementById('return-card-btc');
retEl.innerText = `${percent >= 0 ? '+' : ''}${percent.toFixed(1)}%`;
retEl.className = `text-lg font-bold ${percent >= 0 ? 'text-green-400' : 'text-red-400'}`;
}
const currentUsdVal = currentNetHeld * price;
const totalPnl = currentUsdVal - currentBuyCost;
const pnlPercent = currentBuyCost > 0 ? (totalPnl / currentBuyCost) * 100 : 0;
document.getElementById('current-usd-val').innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(currentUsdVal);
const pnlEl = document.getElementById('total-pnl-val');
pnlEl.innerText = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(totalPnl);
pnlEl.className = `text-xl font-bold ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`;
const pnlPctEl = document.getElementById('pnl-percent-val');
pnlPctEl.innerText = `${totalPnl >= 0 ? '+' : ''}${pnlPercent.toFixed(2)}%`;
pnlPctEl.className = `text-xs font-bold mt-1 ${totalPnl >= 0 ? 'text-green-400' : 'text-red-400'}`;
}
function setupCumulCard(cutoff) {
const filteredData = currentAggregatedSeries.filter(d => d[0] >= cutoff);
const options = getBaseChartOptions('instance-cumul', filteredData, orangeBrandColor, false);
const chart = new ApexCharts(document.querySelector("#chart-container-cumul"), options);
chart.render();
window.cumulChart = chart;
}
function setupSatsCard(cutoff) {
const rawFiltered = dailySatsData.filter(d => d.ts >= cutoff);
const rollingAvg = rawFiltered.map(d => [d.ts, Math.round(d.rolling_sats)]);
const latestAvg = rollingAvg.length > 0 ? rollingAvg[rollingAvg.length - 1][1] : 0;
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(Math.round(latestAvg));
const options = {
chart: { id: 'instance-sats', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
series: [{ name: 'Avg Sats/Day', data: rollingAvg }],
dataLabels: { enabled: false },
colors: [orangeBrandColor],
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [{ offset: 0, color: orangeBrandColor, opacity: 0.16 }, { offset: 100, color: orangeBrandColor, opacity: 0 }] } },
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
markers: { size: 0, hover: { size: 5 } },
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false } },
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => { if (val >= 1e6) return (val / 1e6).toFixed(0) + 'M'; if (val >= 1e3) return (val / 1e3).toFixed(0) + 'k'; return val; } }, axisBorder: { show: false }, axisTicks: { show: false } },
tooltip: { enabled: true, theme: 'dark', shared: false, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const item = rawFiltered[dataPointIndex]; if (!item) return ''; const date = new Date(item.ts); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const btcVal = parseFloat(item.btc_val).toFixed(4); const price = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(item.price); const dollarDay = parseFloat(item.btc_val) * item.price; const dollarFormatted = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(dollarDay); return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="mb-3"><div class="text-orange-400 text-xs font-bold uppercase mb-1">₿ ' + btcVal + '/day</div><div class="text-gray-300 text-xs">Dollar Amount/Day</div><div class="text-white text-lg font-medium">' + dollarFormatted + '</div></div><div class="border-t border-gray-600/50 pt-2.5"><div class="text-gray-400 text-[10px] uppercase mb-1">Calculation</div><div class="text-gray-300 text-xs">₿ ' + btcVal + ' × ' + price + '</div></div></div>'; } },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
};
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
chart.render();
window.satsChart = chart;
}
function setupBreakdownCard(cutoff) {
const opts = {
chart: { id: 'instance-breakdown', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false } },
series: [
{ name: 'Total Holdings', data: totalCumulData.filter(d => d[0] >= cutoff) },
{ name: 'On Aave', data: aaveCumulData.filter(d => d[0] >= cutoff) }
],
dataLabels: { enabled: false },
colors: [orangeBrandColor, blueBrandColor],
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: [[{ offset: 0, color: orangeBrandColor, opacity: 0.16 }, { offset: 100, color: orangeBrandColor, opacity: 0 }], [{ offset: 0, color: blueBrandColor, opacity: 0.16 }, { offset: 100, color: blueBrandColor, opacity: 0 }]] } },
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
markers: { size: 0, strokeColors: [orangeBrandColor, blueBrandColor], strokeWidth: 3, hover: { size: 5 } },
xaxis: { type: 'datetime', labels: { show: true, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, datetimeFormatter: { year: 'yyyy', month: 'MMM yy', day: 'dd MMM' } }, axisBorder: { show: false }, axisTicks: { show: false } },
yaxis: { opposite: true, labels: { offsetX: -10, style: { colors: '#4B5563', fontSize: '10px', fontWeight: 600 }, formatter: (val) => val.toFixed(4) }, axisBorder: { show: false }, axisTicks: { show: false } },
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) { const item = totalCumulData.filter(d => d[0] >= cutoff)[dataPointIndex]; if (!item) return ''; const date = new Date(item[0]); const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const total = item[1]; const aave = aaveCumulData.filter(d => d[0] >= cutoff)[dataPointIndex]?.[1] || 0; const delta = total - aave; return '<div class="rounded-lg py-2 px-4 text-left"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter mb-3">' + dateString + '</div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + orangeBrandColor + ';"></div><span class="text-gray-300 text-xs">Total</span></div><span class="text-orange-400 text-sm font-bold">' + total.toFixed(6) + '</span></div><div class="flex items-center justify-between mb-2"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color:' + blueBrandColor + ';"></div><span class="text-gray-300 text-xs">On Aave</span></div><span class="text-blue-400 text-sm font-bold">' + aave.toFixed(6) + '</span></div><div class="flex items-center justify-between"><div class="flex items-center gap-1.5"><div class="w-2.5 h-2.5 rounded-full" style="background-color: #22c55e;"></div><span class="text-gray-300 text-xs">Delta</span></div><span class="text-green-400 text-sm font-bold">' + delta.toFixed(6) + '</span></div></div>'; } },
legend: { position: 'top', horizontalAlign: 'left', fontSize: '10px', fontFamily: 'sans-serif', labels: { colors: '#4B5563' }, markers: { width: 8, height: 8, radius: 12, offsetX: -4 }, itemMargin: { horizontal: 8, vertical: 0 } },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
};
const chart = new ApexCharts(document.querySelector("#chart-container-breakdown"), opts);
chart.render();
window.breakdownChart = chart;
}
function startTickUpdates(data) {
tickIntervalId = setInterval(() => {
const tickShift = (Math.random() * 16) - 8;
let currentVal = data[data.length - 1][1] + tickShift;
data[data.length - 1][1] = parseFloat(currentVal.toFixed(2));
const initialPrice = data[0][1];
const percentChange = ((currentVal - initialPrice) / initialPrice) * 100;
updateBtcUI(currentVal, percentChange);
todayBtcPrice = currentVal;
if (window.btcChart) {
const currentXMin = window.btcChart.w.globals.minX;
const currentXMax = window.btcChart.w.globals.maxX;
window.btcChart.updateOptions({ series: [{ data: data }], xaxis: { min: currentXMin, max: currentXMax } }, false, false, false);
}
}, 2500);
}
/* ===================================================================
Global Event Listeners
=================================================================== */
document.querySelectorAll('.filter-checkbox').forEach(cb => {
cb.addEventListener('change', () => updateDashboard());
});
/* ===================================================================
Sync Polling
=================================================================== */
async function pollSyncStatus() {
const selectedAddr = getSelectedAddresses();
const syncing = selectedAddr.filter(a => walletSyncState[a] === 'syncing');
if (syncing.length === 0) return;
const promises = syncing.map(async (addr) => {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${addr}/avve`);
if (resp.status === 400) {
const body = await resp.json().catch(() => ({}));
const details = (body?.detail || body?.details || '').toString().toUpperCase();
if (details.includes('PENDING') || details.includes('SYNCING')) {
walletSyncState[addr] = 'syncing';
} else {
walletSyncState[addr] = 'synced';
}
return;
}
if (!resp.ok) return;
const events = await resp.json();
walletSyncState[addr] = 'synced';
addressSnapshots[addr] = snapshotsToDaily(events);
renderCombinedTable();
updateDashboard();
} catch (err) {
console.error('Sync poll failed for ' + addr, err);
}
});
await Promise.all(promises);
renderWalletPills();
}
function startSyncPolling() {
setInterval(() => pollSyncStatus(), 15000);
}
/* ===================================================================
Initialization
=================================================================== */
async function initDashboardGrid() {
/* Seed penguin in localStorage if not present already */
if (!wm.findWallet(penguinAddress, 'base')) {
wm.addWallet(penguinAddress, 'base', 'penguin');
walletSyncState[penguinAddress] = 'syncing';
/* Auto-select the default wallet */
localStorage.setItem('cbbtc_selected', JSON.stringify([penguinAddress]));
}
renderWalletPills();
renderLedgerFilterWallets();
try {
/* Fetch BTC price */
const response = await fetch('https://api.kraken.com/0/public/OHLC?pair=XBTUSD&interval=1440');
const result = await response.json();
const rawPrices = result.result.XXBTZUSD;
btcPriceData = rawPrices.map(item => [item[0] * 1000, parseFloat(item[4])]);
/* Fetch embedded wallet data */
await fetchAaveSnapshots();
/* Fetch data for all selected wallets */
await fetchSelectedWalletsData();
await refreshPrices();
renderCombinedTable();
const cutoff = getOldestTransactionDate();
currentCutoffMs = cutoff;
refreshAllCharts(cutoff);
startSyncPolling();
startPolling();
} catch (error) {
console.error("Dashboard error:", error);
}
}
async function pollAaveUpdate() {
const now = Date.now();
if (now - lastFetchMs < 30000) return;
lastFetchMs = now;
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${penguinAddress}/base/aave`);
if (!resp.ok) return;
const events = await resp.json();
lastFetchMs = Date.now();
const newestTs = events[events.length - 1].block_timestamp;
if (newestTs > (allAaveSnapshots[0]?.block_timestamp || '')) {
allAaveSnapshots = snapshotsToDaily(events);
addressSnapshots[penguinAddress] = allAaveSnapshots;
await refreshPrices();
renderCombinedTable();
}
} catch (err) {
console.error("Poll update failed:", err);
}
}
function startPolling() {
setInterval(() => pollAaveUpdate(), 60000);
}
initDashboardGrid();
</script>
</body>
</html>