Compare commits

...

10 Commits

Author SHA1 Message Date
d96e56bde5 feat: draggable wallet reorder for chart layering 2026-06-11 19:16:18 +00:00
b52da96ba0 Fix avg buy price calculation by iterating snapshots oldest-first 2026-06-11 10:47:44 +00:00
8606673928 feat: stacked per-wallet area chart for Avg BTC Acquired/Day 2026-06-11 09:22:03 +00:00
80a7449688 Convert BTC Treasury Growth chart to stacked multi-wallet area chart 2026-06-11 08:43:25 +00:00
7a34190956 Generate daily data points for cumulative and breakdown charts 2026-06-11 07:24:36 +00:00
4f5a28ae84 fix: forward-fill wallet balances in chart aggregation for multi-wallet continuity 2026-06-11 06:21:42 +00:00
fab41179d0 Add double-click to reset chart zoom on all charts 2026-06-10 20:27:00 +00:00
e163f83963 Fix chart accumulation bug: render daily balance instead of cumulative sum 2026-06-10 20:05:58 +00:00
88c8f7927d feat: add lendingPlatform field to wallet state 2026-06-10 19:52:47 +00:00
e28e66b29f fix: scan all snapshots for oldest timestamp in fetchAllWalletData
snapshotsToDaily returns results sorted newest-first, so allSnaps[0] was
the newest timestamp. This caused fetchPrices to compute a near-zero date
range, resulting in $0 values for all but the most recent rows.

Also removed redundant refreshPrices() call from init flow (fetchAllWalletData
already fetches full-range prices). Added table data flow doc to AGENTS.md.
2026-06-10 19:52:11 +00:00
3 changed files with 533 additions and 173 deletions

View File

@ -104,6 +104,22 @@ Cross-tab leader election uses `BroadcastChannel("dione_shared_stream")` with a
- Adding chart data? Update the embedded arrays (`walletCumulData`, etc.) — they are plain JS arrays of `[timestampMs, value]` pairs.
- `wallets.js` always forces `isVerified: false` on `loadWallets()` — persisted signatures are discarded. Verification must go through `WalletVerifier`.
## Transaction Ledger Table — Data Flow
How the table gets its rows and values:
1. **Fetch:** `fetchAllWalletData()``fetchWalletAaveData(address)` hits `/api/v1/portfolio/{address}/base/aave`, returns array of snapshot events.
2. **Deduplicate:** `snapshotsToDaily(events)` collapses events to one per day (keeps latest `block_timestamp` per day). **Result is sorted newest-first.**
3. **Store:** Deduplicated snapshots land in `addressSnapshots[address]`.
4. **Prices:** `fetchPrices(symbols, oldestDateMs)` fetches `/api/v1/prices/{symbol}/history?range=N` where N = days between now and oldest snapshot. **Critical:** you must scan all snapshots for the *lowest* `block_timestamp` to compute the correct range. `snapshots[0]` is the *newest* — never assume the array is oldest-first. The `aavePriceMap` is keyed `[priceSymbol][dateStr] = closePrice`.
5. **Render:** `renderCombinedTable()` iterates `addressSnapshots`, computes row values:
- `getTokenAmount(raw, symbol)` divides by token decimals (cbBTC /1e8, WETH /1e18, USDC /1e6)
- `priceForToken(symbol, dateStr)` looks up `aavePriceMap` for the matching date (falls back to nearest prior date)
- Each row shows USD = `amount × price` for wallet (cold), collateral, and debt columns
6. **Filter:** `updateDashboard()` hides/shows rows based on wallet-type and ledger-wallet checkbox filters.
If table rows show `$0` for non-recent dates, check that `aavePriceMap` covers the full historical range — the `fetchPrices()` call must use the *oldest* snapshot timestamp, not the newest.
## Coding Guidelines
You are an expert senior Web3 frontend engineer specializing in high-scale performance and stateless cryptographic security.

View File

@ -109,6 +109,17 @@ window.WalletManager = WalletManager;
::-webkit-scrollbar-track { background: #05070B; }
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #374151; }
/* Draggable pills */
#wallet-pills { display: flex; align-items: center; gap: 6px; }
#wallet-pills .pill-btn { user-select: none; position: relative; }
#wallet-pills .pill-btn.dragging { opacity: 0.3; transform: scale(0.95); }
#wallet-pills .pill-btn.pill-dragging { opacity: 0.3; transform: scale(0.95); }
.drag-insert-line {
width: 3px; min-height: 32px; background: #FF7A00;
border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px #FF7A00;
position: absolute; left: -3px; top: 0;
}
</style>
</head>
<body class="p-4 md:p-8 min-h-screen text-white">
@ -259,8 +270,8 @@ window.WalletManager = WalletManager;
<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>
<span class="text-lg font-bold" style="color: #FF7A00;">RUNNING AVG</span>
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Sats per Day</p>
</div>
</div>
<div class="w-full h-52 mt-4" id="chart-container-sats"></div>
@ -384,16 +395,29 @@ window.WalletManager = WalletManager;
w.nickname = String(nickname).trim();
this._persist();
}
verifyWallet(address, chain, signature, messageData) {
const w = this.findWallet(address, chain);
if (!w) return { success: false, error: 'Wallet not found' };
w.isVerified = true;
w.signature = String(signature);
w.messageData = messageData || null;
this._persist();
return { success: true };
}
};
verifyWallet(address, chain, signature, messageData) {
const w = this.findWallet(address, chain);
if (!w) return { success: false, error: 'Wallet not found' };
w.isVerified = true;
w.signature = String(signature);
w.messageData = messageData || null;
this._persist();
return { success: true };
}
setWalletOrder(addresses) {
const addrSet = this._wallets.map(w => w.address);
const ordered = [];
for (const addr of addresses) {
if (addrSet.includes(addr)) {
const w = this._wallets.find(x => x.address === addr);
if (w) ordered.push(w);
}
}
const remaining = this._wallets.filter(w => !ordered.includes(w));
this._wallets = [...ordered, ...remaining];
this._persist();
}
};
}
})();
@ -404,7 +428,7 @@ 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"}};
const TOKENS = {"cbBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WBTC": {"decimals": 8, "priceSymbol": "BTC"}, "WETH": {"decimals": 18, "priceSymbol": "WETH"}, "USDC": {"decimals": 6, "priceSymbol": "USDC"}};
/* ===================================================================
App State
@ -413,6 +437,7 @@ const wm = new WalletManager();
wm.loadWallets();
let currentAggregatedSeries = [];
let currentWalletSeries = [];
let currentNetHeld = 0;
let currentBuyCost = 0;
let currentBuyAmount = 0;
@ -861,7 +886,21 @@ async function handleAddWallet(e) {
closeSidebar();
walletSyncState[address] = 'syncing';
await _pollUntilSynced(address);
/* Register the address with the backend for monitoring */
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/monitor`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, chain, chain_id: CHAIN_IDS[chain] || 8453 })
});
if (!resp.ok) {
console.warn(`Backend registration returned ${resp.status} for ${address} — continuing with polling`);
}
} catch (err) {
console.warn(`Error registering wallet ${address} with backend:`, err);
}
await _pollUntilSynced(address, undefined, undefined, chain);
renderSidebar();
renderWalletPills();
renderLedgerFilterWallets();
@ -934,7 +973,7 @@ async function handleVerifyWallet(address, chain, nickname) {
const result = await verifyOwnership(address, chain, nickname || w.nickname);
if (result.success) {
/* Fetch data now that wallet is verified */
if (chain === 'base') fetchWalletAaveData(address);
fetchWalletAaveData(address, chain);
} else {
/* Remove unverified wallet on failure */
wm.removeWallet(address, chain);
@ -992,8 +1031,8 @@ function renderWalletPills() {
const nick = w.nickname || 'Wallet';
const isActive = isWalletInLedger(w.address);
const platformBadge = getPlatformBadge(w.lendingPlatform);
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 + '\')">' +
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 + '" draggable="true">' +
'<input type="checkbox" class="wallet-filter-toggle" style="--wallet-color: ' + color + ';" data-address="' + w.address + '" ' + (isActive ? 'checked' : '') + ' onchange="if(!_dragAddr){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 + platformBadge + '</span>' +
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
@ -1001,6 +1040,7 @@ function renderWalletPills() {
'</label>';
});
container.innerHTML = html;
setupPillDragListeners(container);
}
function toggleWalletPill(address) {
@ -1008,6 +1048,117 @@ function toggleWalletPill(address) {
renderLedgerFilterWallets();
}
/* ===================================================================
Drag Reorder (wallet pills)
=================================================================== */
let _dragAddr = null;
let _dropTargetAddr = null;
let _dropBefore = true;
function startPillDrag(e) {
if (!e.currentTarget.dataset.address) return;
_dragAddr = e.currentTarget.dataset.address;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', _dragAddr);
}
e.stopPropagation();
e.currentTarget.classList.add('pill-dragging');
}
function endPillDrag(e) {
e.currentTarget.classList.remove('pill-dragging');
cleanupDrag();
}
function cleanupDrag() {
_dragAddr = null;
_dropTargetAddr = null;
_dropBefore = true;
document.querySelectorAll('.pill-dragging').forEach(el => el.classList.remove('pill-dragging'));
document.querySelectorAll('.drag-insert-line').forEach(el => el.remove());
}
function dropWalletReorder(e, targetAddress) {
e.preventDefault();
e.stopPropagation();
if (!_dragAddr) return cleanupDrag();
const wallets = wm.getWallets();
const addrs = wallets.map(w => w.address);
const dragIdx = addrs.indexOf(_dragAddr);
if (dragIdx === -1) return cleanupDrag();
if (targetAddress && targetAddress !== _dragAddr) {
const targetIdx = addrs.indexOf(targetAddress);
if (targetIdx !== -1) {
const moved = addrs.splice(dragIdx, 1)[0];
const insertIdx = _dropBefore ? targetIdx : targetIdx + 1;
addrs.splice(insertIdx, 0, moved);
wm.setWalletOrder(addrs);
renderAll();
}
} else if (!_dropTargetAddr) {
const moved = addrs.splice(dragIdx, 1)[0];
addrs.push(moved);
wm.setWalletOrder(addrs);
renderAll();
}
cleanupDrag();
}
function setupPillDragListeners(container) {
container.querySelectorAll('.pill-btn').forEach(pill => {
pill.addEventListener('dragstart', startPillDrag);
pill.addEventListener('dragend', endPillDrag);
pill.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
_dropTargetAddr = pill.dataset.address;
const rect = pill.getBoundingClientRect();
_dropBefore = e.clientX < rect.left + rect.width / 2;
});
pill.addEventListener('dragleave', () => { document.querySelector('.drag-insert-line')?.remove(); });
pill.addEventListener('drop', (e) => { dropWalletReorder(e, pill.dataset.address); });
});
}
const pillsContainer = document.getElementById('wallet-pills');
pillsContainer.addEventListener('dragover', (e) => {
if (!_dragAddr) return;
document.querySelector('.drag-insert-line')?.remove();
const children = Array.from(pillsContainer.children).filter(c => c.classList && c.classList.contains('pill-btn'));
for (const child of children) {
if (child.dataset.address === _dragAddr) continue;
const rect = child.getBoundingClientRect();
if (e.clientX < rect.left + rect.width / 2) {
const line = document.createElement('div');
line.className = 'drag-insert-line';
pillsContainer.insertBefore(line, child);
break;
}
}
});
pillsContainer.addEventListener('drop', (e) => {
if (!_dragAddr || e.target.classList.contains('pill-btn')) return;
e.preventDefault();
e.stopPropagation();
const wallets = wm.getWallets();
const addrs = wallets.map(w => w.address);
const dragIdx = addrs.indexOf(_dragAddr);
if (dragIdx !== -1) {
const moved = addrs.splice(dragIdx, 1)[0];
addrs.push(moved);
wm.setWalletOrder(addrs);
renderAll();
}
cleanupDrag();
});
function getSelectedAddresses() {
const unchecked = getWalletFilter();
const wallets = wm.getWallets();
@ -1047,8 +1198,9 @@ function renderLedgerFilterWallets() {
/* ===================================================================
Dynamic Data Fetching (Promise.all aggregation)
=================================================================== */
async function fetchWalletAaveData(address) {
const endpoint = `/api/v1/portfolio/${address}/base/aave`;
async function fetchWalletAaveData(address, chain) {
chain = chain || 'base';
const endpoint = `/api/v1/portfolio/${address}/${chain}/aave`;
try {
const resp = await fetch(`${API_BASE}${endpoint}`);
if (resp.status === 400) {
@ -1060,6 +1212,11 @@ async function fetchWalletAaveData(address) {
return;
}
}
if (resp.status === 404) {
walletSyncState[address] = 'pending';
renderWalletPills();
return;
}
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const events = await resp.json();
walletSyncState[address] = 'synced';
@ -1073,11 +1230,12 @@ async function fetchWalletAaveData(address) {
}
/* Poll until wallet data is synchronized, up to ~60s */
async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000) {
async function _pollUntilSynced(address, timeoutMs = 60000, intervalMs = 2000, chain) {
chain = chain || 'base';
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${address}/base/aave`);
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${address}/${chain}/aave`);
if (resp.ok) {
const events = await resp.json();
if (Array.isArray(events) && events.length > 0) {
@ -1124,10 +1282,12 @@ async function fetchSelectedWalletsData() {
/* 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 === 'syncing') return null;
if (sync === 'pending') return null;
if (sync === 'synced' && addressSnapshots[addr]) return null; /* Cached */
await fetchWalletAaveData(addr);
if (sync === 'synced' && addressSnapshots[addr]) return null;
const w = wm.getWallets().find(w => w.address === addr);
const chain = (w || {}).chain || 'base';
await fetchWalletAaveData(addr, chain);
});
await Promise.all(promises);
}
@ -1140,6 +1300,15 @@ function getTokenAmount(raw, symbol) {
return parseFloat(raw || '0') / Math.pow(10, dec);
}
function getTotalBTC(obj) {
let total = 0;
if (!obj) return 0;
for (const sym of Object.keys(TOKENS)) {
if (TOKENS[sym].priceSymbol === 'BTC') total += getTokenAmount(obj[sym], sym);
}
return total;
}
function priceForToken(symbol, dateStr) {
const today = new Date().toISOString().slice(0, 10);
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
@ -1217,30 +1386,56 @@ function getOldestTransactionDate() {
return oldestTs === Infinity ? Date.now() - 365 * 86400000 : oldestTs;
}
/* Build per-day cumulative BTC series from addressSnapshots */
/* Build per-day cumulative BTC series from addressSnapshots.
Returns both aggregated total and per-wallet series. */
function buildCumulativeSeries(addresses) {
const dayMap = {};
const allDates = {};
addresses.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => {
const ts = new Date(snap.block_timestamp).getTime();
const dateStr = snap.block_timestamp.slice(0, 10);
const btcAmt = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
if (!dayMap[ts]) dayMap[ts] = { total: 0, dateStr };
dayMap[ts].total += btcAmt;
allDates[snap.block_timestamp.slice(0, 10)] = true;
});
});
const sorted = Object.values(dayMap).sort((a, b) => {
const tsA = new Date(a.dateStr).getTime();
const tsB = new Date(b.dateStr).getTime();
return tsA - tsB;
});
let cumul = 0;
return sorted.map(d => {
cumul += d.total;
const dateStr = d.dateStr;
return [new Date(dateStr).getTime(), cumul];
const snapshotDates = Object.keys(allDates).sort();
if (snapshotDates.length === 0) return { total: [], perWallet: [] };
/* Build per-wallet forward-filled balance for every calendar day */
const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate());
const lastUTC = Date.UTC(new Date(snapshotDates[snapshotDates.length - 1]).getUTCFullYear(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCMonth(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCDate());
const DAY_MS = 86400000;
/* Pre-compute per-wallet dated arrays (oldest-first) */
const walletDated = {};
addresses.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
walletDated[addr] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
btcAmt: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
});
/* Walk every calendar day, forward-fill each wallet, aggregate */
const totalResult = [];
const perWalletResult = {};
addresses.forEach(addr => { perWalletResult[addr] = []; });
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10);
let dayTotal = 0;
addresses.forEach(addr => {
const dated = walletDated[addr];
let balance = 0;
for (const d of dated) {
if (d.dateStr <= ds) balance = d.btcAmt;
else break;
}
dayTotal += balance;
perWalletResult[addr].push([t, balance]);
});
totalResult.push([t, dayTotal]);
}
return { total: totalResult, perWallet: perWalletResult };
}
function calculateAggregatedSeries() {
@ -1250,10 +1445,13 @@ function calculateAggregatedSeries() {
if (selectedAddr.length === 0) {
currentAggregatedSeries = [];
currentWalletSeries = [];
return;
}
currentAggregatedSeries = buildCumulativeSeries(selectedAddr);
const result = buildCumulativeSeries(selectedAddr);
currentAggregatedSeries = result.total;
currentWalletSeries = result.perWallet;
}
function calculateCurrentHoldings() {
@ -1268,22 +1466,22 @@ function calculateCurrentHoldings() {
const snaps = addressSnapshots[addr] || [];
if (snaps.length > 0) {
const latest = snaps[0];
currentNetHeld += getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
currentNetHeld += getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
currentNetHeld += getTotalBTC(latest?.wallet);
currentNetHeld += getTotalBTC(latest?.collateral);
}
});
/* Derive buy cost from snapshot deltas: positive cbBTC changes across days */
/* Derive buy cost from snapshot deltas: positive BTC changes, oldest first */
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
if (snaps.length < 2) return;
if (snaps.length === 0) return;
let prevBtc = 0;
snaps.forEach(snap => {
snaps.slice().reverse().forEach(snap => {
const currentDate = snap.block_timestamp.slice(0, 10);
const currentBtc = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
const currentBtc = getTotalBTC(snap?.wallet) + getTotalBTC(snap?.collateral);
const delta = currentBtc - prevBtc;
if (delta > 0) {
const price = priceForToken('cbBTC', currentDate) || 0;
const price = priceForToken('BTC', currentDate) || 0;
if (price > 0) currentBuyCost += delta * price;
currentBuyAmount += delta;
}
@ -1297,7 +1495,9 @@ function updateDashboard() {
calculateCurrentHoldings();
if (window.cumulChart) {
window.cumulChart.updateSeries([{ data: currentAggregatedSeries }]);
window.cumulChart.destroy();
window.cumulChart = null;
refreshAllCharts(currentCutoffMs);
}
document.getElementById('net-held-val').innerText = currentNetHeld.toFixed(6);
@ -1486,11 +1686,16 @@ function renderCombinedTable() {
=================================================================== */
async function fetchAllWalletData() {
const verifiedWallets = wm.getVerifiedWallets() || [];
const promises = verifiedWallets.map(async (w) => fetchWalletAaveData(w.address));
const promises = verifiedWallets.map(async (w) => fetchWalletAaveData(w.address, w.chain));
await Promise.all(promises);
const allSnaps = Object.values(addressSnapshots).flat();
if (allSnaps.length > 0) {
const oldestTs = new Date(allSnaps[0].block_timestamp).getTime();
/* snapshots are sorted newest-first; find the oldest */
let oldestTs = Infinity;
allSnaps.forEach(s => {
const t = new Date(s.block_timestamp).getTime();
if (t < oldestTs) oldestTs = t;
});
await fetchPrices(Object.keys(TOKENS), oldestTs);
}
}
@ -1508,7 +1713,7 @@ function getBaseChartOptions(chartId, dataset, color, isBTC) {
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 } },
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 }, minTickInterval: 86400000 },
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 } }
@ -1562,8 +1767,67 @@ function updateBtcUI(price, percent) {
}
function setupCumulCard(cutoff) {
const filteredData = currentAggregatedSeries.filter(d => d[0] >= cutoff);
const options = getBaseChartOptions('instance-cumul', filteredData, orangeBrandColor, false);
const selectedAddr = getSelectedAddresses();
const wallets = wm.getWallets();
const series = selectedAddr.map(addr => {
const w = wallets.find(x => x.address === addr);
const nickname = w ? (w.nickname || addr.slice(0, 6)) : addr.slice(0, 6);
const ws = currentWalletSeries[addr] || [];
return {
name: nickname,
data: ws.filter(d => d[0] >= cutoff)
};
});
const colors = selectedAddr.map(addr => getColorForWallet(addr));
const gradientStops = colors.map(c => [
{ offset: 0, color: c, opacity: 0.35 },
{ offset: 100, color: c, opacity: 0 }
]);
const filteredTotal = currentAggregatedSeries.filter(d => d[0] >= cutoff);
let yMin = 0, yMax = 0;
if (filteredTotal.length > 0) {
const vals = filteredTotal.map(d => d[1]);
yMin = Math.min(...vals) * 0.95;
yMax = Math.max(...vals) * 1.05;
}
const options = {
chart: { id: 'instance-cumul', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, sparkline: { enabled: false }, zoom: { enabled: true, type: 'x', autoScaleYaxis: true }, animations: { enabled: false }, stacked: true },
stackType: 'normal',
series: series,
dataLabels: { enabled: false },
colors: colors,
stroke: { curve: 'smooth', width: 2 },
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: gradientStops } },
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 }, minTickInterval: 86400000 },
yaxis: { opposite: true, min: yMin, max: yMax, 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 rawData = w.config.series[0].data[dataPointIndex];
if (!rawData) return '';
const date = new Date(rawData[0]);
const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
let html = '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div>';
let total = 0;
w.config.series.forEach((s, i) => {
const pt = s.data[dataPointIndex];
if (pt) {
total += pt[1];
html += '<div class="flex items-center justify-between text-xs mt-1"><div class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full" style="background:' + colors[i] + ';"></div><span class="text-gray-300">' + s.name + '</span></div><span class="text-white font-bold">' + pt[1].toFixed(6) + '</span></div>';
}
});
html += '<div class="border-t border-gray-600 mt-1 pt-1 flex items-center justify-between text-xs"><span class="text-gray-300">Total</span><span class="text-white font-bold">' + total.toFixed(6) + ' BTC</span></div>';
html += '</div>';
return html;
} },
legend: { show: false },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
};
const chart = new ApexCharts(document.querySelector("#chart-container-cumul"), options);
chart.render();
window.cumulChart = chart;
@ -1571,65 +1835,113 @@ function setupCumulCard(cutoff) {
function setupSatsCard(cutoff) {
const DAY_MS = 86400000;
/* Derive daily balances (latest snapshot per day) using UTC date strings */
const dailyBalances = {};
/* Collect all dates across selected wallets, then forward-fill each wallet's balance */
const allDates = {};
const selectedAddr = getSelectedAddresses() || [];
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => {
const utcTs = new Date(snap.block_timestamp).getTime();
const dateStr = new Date(utcTs).toISOString().slice(0, 10);
const btcVal = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
if (!dailyBalances[dateStr] || utcTs > dailyBalances[dateStr].ts) {
dailyBalances[dateStr] = { ts: utcTs, dateStr, btcVal };
} else {
dailyBalances[dateStr].btcVal += btcVal;
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
});
const sortedDates = Object.keys(allDates).sort();
const wallets = wm.getWallets();
/* Per-wallet forward-fill */
const walletDailyBalances = {};
selectedAddr.forEach(addr => {
walletDailyBalances[addr] = {};
const snaps = addressSnapshots[addr] || [];
const dated = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
btcVal: getTotalBTC(s?.wallet) + getTotalBTC(s?.collateral)
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
let balance = 0;
for (const ds of sortedDates) {
const match = dated.find(d => d.dateStr === ds);
if (match) balance = match.btcVal;
walletDailyBalances[addr][ds] = balance;
}
});
/* Per-wallet daily deltas */
const walletDailyDeltas = {};
selectedAddr.forEach(addr => {
walletDailyDeltas[addr] = {};
let prev = 0;
for (const ds of sortedDates) {
const b = walletDailyBalances[addr][ds] || 0;
walletDailyDeltas[addr][ds] = b - prev;
prev = b;
}
});
const firstUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[0]).getUTCFullYear(), new Date(sortedDates[0]).getUTCMonth(), new Date(sortedDates[0]).getUTCDate()) : Date.now();
const lastUTC = sortedDates.length > 0 ? Date.UTC(new Date(sortedDates[sortedDates.length - 1]).getUTCFullYear(), new Date(sortedDates[sortedDates.length - 1]).getUTCMonth(), new Date(sortedDates[sortedDates.length - 1]).getUTCDate()) : Date.now();
const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS));
/* Per-wallet series: running avg sats/day */
const series = selectedAddr.map(addr => {
const w = wallets.find(x => x.address === addr);
const nickname = w ? (w.nickname || addr.slice(0, 6)) : addr.slice(0, 6);
const data = [];
let runningTotalBtc = 0;
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10);
runningTotalBtc += walletDailyDeltas[addr][ds] || 0;
const daysSoFar = Math.max(1, Math.ceil((t - firstUTC) / DAY_MS) + 1);
const runningAvgSats = Math.round((runningTotalBtc * 1e8) / daysSoFar);
if (t >= cutoff) {
data.push([t, runningAvgSats]);
}
});
}
return { name: nickname, data };
});
/* Sort by date, compute daily delta (acquisition) */
const sortedDays = Object.values(dailyBalances).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
const dailyDeltas = {};
sortedDays.forEach((d, i) => {
const prevBalance = i > 0 ? sortedDays[i - 1].btcVal : 0;
dailyDeltas[d.dateStr] = d.btcVal - prevBalance;
});
const latestTotal = sortedDates.length > 0 ? (() => {
let s = 0;
selectedAddr.forEach(addr => { s += walletDailyBalances[addr][sortedDates[sortedDates.length - 1]] || 0; });
return s;
})() : 0;
const avgSatsPerDay = Math.round((latestTotal * 1e8) / daysElapsed);
document.getElementById('avg-sats-val').innerText = new Intl.NumberFormat('en-US').format(avgSatsPerDay);
/* Fill continuous daily timeline — iterate every day from first to last */
const firstUTC = Date.UTC(new Date(sortedDays[0].dateStr).getUTCFullYear(), new Date(sortedDays[0].dateStr).getUTCMonth(), new Date(sortedDays[0].dateStr).getUTCDate());
const lastUTC = Date.UTC(new Date(sortedDays[sortedDays.length - 1].dateStr).getUTCFullYear(), new Date(sortedDays[sortedDays.length - 1].dateStr).getUTCMonth(), new Date(sortedDays[sortedDays.length - 1].dateStr).getUTCDate());
const continuousDays = [];
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10);
continuousDays.push({ ts: t, dateStr: ds, delta: dailyDeltas[ds] || 0 });
}
const colors = selectedAddr.map(addr => getColorForWallet(addr));
const gradientStops = colors.map(c => [
{ offset: 0, color: c, opacity: 0.35 },
{ offset: 100, color: c, opacity: 0 }
]);
/* Exponentially weighted moving average — older days decay so average drops when there are no acquisitions */
const filtered = continuousDays.filter(d => d.ts >= cutoff);
const halfLife = 7;
const decay = Math.pow(0.5, 1 / halfLife);
let ewmaVal = filtered[0]?.delta || 0;
const emaSats = [];
for (let i = 0; i < filtered.length; i++) {
ewmaVal = filtered[i].delta + decay * ewmaVal;
emaSats.push([filtered[i].ts, Math.round(ewmaVal * 1e8)]);
}
const latestAvg = emaSats.length > 0 ? emaSats[emaSats.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: emaSats }],
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 }, stacked: true },
stackType: 'normal',
series: series,
dataLabels: { enabled: false },
colors: [orangeBrandColor],
colors: colors,
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 }] } },
fill: { type: 'gradient', gradient: { type: 'vertical', shadeIntensity: 0, colorStops: gradientStops } },
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 } },
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 }, minTickInterval: 86400000 },
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 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 sats = rawData[1]; const btcVal = (sats / 1e8).toFixed(6); const priceStr = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(todayBtcPrice) : '—'; const dollarDay = todayBtcPrice ? new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format((sats / 1e8) * todayBtcPrice) : '—'; 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="text-orange-400 text-xs font-bold">₿ ' + btcVal + '/day</div><div class="text-white text-sm font-medium mt-1">' + dollarDay + '</div></div>'; } },
tooltip: { enabled: true, theme: 'dark', shared: true, intersect: false, custom: function({ series, seriesIndex, dataPointIndex, w }) {
const rawData = w.config.series[0].data[dataPointIndex];
if (!rawData) return '';
const date = new Date(rawData[0]);
const dateString = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
let html = '<div class="text-center font-medium relative p-1"><div class="text-gray-400 text-[10px] font-semibold uppercase tracking-tighter">' + dateString + '</div>';
let totalSats = 0;
w.config.series.forEach((s, i) => {
const pt = s.data[dataPointIndex];
if (pt) {
totalSats += pt[1];
const btcVal = (pt[1] / 1e8).toFixed(6);
html += '<div class="flex items-center justify-between text-xs mt-1"><div class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full" style="background:' + colors[i] + ';"></div><span class="text-gray-300">' + s.name + '</span></div><span class="text-white font-bold">\u20BF ' + btcVal + '</span></div>';
}
});
html += '<div class="border-t border-gray-600 mt-1 pt-1 flex items-center justify-between text-xs"><span class="text-gray-300">Total</span><span class="text-white font-bold">' + new Intl.NumberFormat('en-US').format(totalSats) + ' sats</span></div>';
html += '</div>';
return html;
} },
legend: { show: false },
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
};
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
@ -1639,28 +1951,61 @@ function setupSatsCard(cutoff) {
function setupBreakdownCard(cutoff) {
/* Derive from snapshots: cold wallet vs collateral */
const dayMap = {};
const allDates = {};
const selectedAddr = getSelectedAddresses() || [];
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
snaps.forEach(snap => {
const ts = new Date(snap.block_timestamp).getTime();
if (!dayMap[ts]) dayMap[ts] = { cold: 0, collateral: 0 };
dayMap[ts].cold += getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC');
dayMap[ts].collateral += getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
});
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
});
const entries = Object.keys(dayMap).map(ts => parseInt(ts)).sort((a, b) => a - b);
let c0 = 0, c1 = 0;
const totalSeries = entries.map(ts => {
c0 += dayMap[ts].cold + dayMap[ts].collateral;
return [ts, c0];
}).filter(d => d[0] >= cutoff);
let c2 = 0;
const aaveSeries = entries.map(ts => {
c2 += dayMap[ts].collateral;
return [ts, c2];
}).filter(d => d[0] >= cutoff);
const snapshotDates = Object.keys(allDates).sort();
if (snapshotDates.length === 0) {
const opts = {
chart: { id: 'instance-breakdown', type: 'area', height: 200, background: 'transparent', toolbar: { show: false }, animations: { enabled: false } },
series: [{ name: 'Total Holdings', data: [] }, { name: 'On Aave', data: [] }],
colors: [orangeBrandColor, blueBrandColor]
};
const chart = new ApexCharts(document.querySelector("#chart-container-breakdown"), opts);
chart.render();
window.breakdownChart = chart;
return;
}
const firstUTC = Date.UTC(new Date(snapshotDates[0]).getUTCFullYear(), new Date(snapshotDates[0]).getUTCMonth(), new Date(snapshotDates[0]).getUTCDate());
const lastUTC = Date.UTC(new Date(snapshotDates[snapshotDates.length - 1]).getUTCFullYear(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCMonth(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCDate());
const DAY_MS = 86400000;
/* Pre-compute per-wallet dated arrays (oldest-first) */
const walletDated = {};
selectedAddr.forEach(addr => {
const snaps = addressSnapshots[addr] || [];
walletDated[addr] = snaps.map(s => ({
dateStr: s.block_timestamp.slice(0, 10),
cold: getTotalBTC(s?.wallet),
collateral: getTotalBTC(s?.collateral)
})).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
});
/* Walk every calendar day, forward-fill each wallet, aggregate */
const totalSeries = [];
const aaveSeries = [];
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
const ds = new Date(t).toISOString().slice(0, 10);
let dayCold = 0, dayColl = 0;
selectedAddr.forEach(addr => {
const dated = walletDated[addr];
let c = 0, co = 0;
for (const d of dated) {
if (d.dateStr <= ds) { c = d.cold; co = d.collateral; }
else break;
}
dayCold += c;
dayColl += co;
});
if (t >= cutoff) {
totalSeries.push([t, dayCold + dayColl]);
aaveSeries.push([t, dayColl]);
}
}
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 } },
@ -1674,7 +2019,7 @@ function setupBreakdownCard(cutoff) {
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 } },
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 }, minTickInterval: 86400000 },
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 = totalSeries[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 = aaveSeries[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 } },
@ -1703,50 +2048,28 @@ function startTickUpdates(data) {
}
/* ===================================================================
Global Event Listeners
=================================================================== */
Double-click to reset zoom
=================================================================== */
function resetChartZoom(chartInstance) {
if (chartInstance) {
chartInstance.updateOptions({
xaxis: { min: undefined, max: undefined }
}, false, false, true);
}
}
document.getElementById('chart-container-btc').addEventListener('dblclick', () => resetChartZoom(window.btcChart));
document.getElementById('chart-container-cumul').addEventListener('dblclick', () => resetChartZoom(window.cumulChart));
document.getElementById('chart-container-sats').addEventListener('dblclick', () => resetChartZoom(window.satsChart));
document.getElementById('chart-container-breakdown').addEventListener('dblclick', () => resetChartZoom(window.breakdownChart));
/* ===================================================================
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}/base/aave`);
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);
}
/* ===================================================================
Polling for Verified Wallets
=================================================================== */
@ -1757,7 +2080,7 @@ async function pollVerifiedWallets() {
const verified = wm.getVerifiedWallets() || [];
for (const w of verified) {
try {
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/base/aave`);
const resp = await fetch(`${API_BASE}/api/v1/portfolio/${w.address}/${w.chain}/aave`);
if (!resp.ok) continue;
const events = await resp.json();
const newestTs = events[events.length - 1].block_timestamp;
@ -1801,17 +2124,15 @@ async function initDashboardGrid() {
const rawPrices = result.result.XXBTZUSD;
btcPriceData = rawPrices.map(item => [item[0] * 1000, parseFloat(item[4])]);
/* Fetch data for all verified wallets */
/* Fetch data for all verified wallets (already fetches full-range prices) */
await fetchAllWalletData();
await refreshPrices();
renderCombinedTable();
const cutoff = getOldestTransactionDate();
currentCutoffMs = cutoff;
refreshAllCharts(cutoff);
startSyncPolling();
startPolling();
} catch (error) {
console.error("Dashboard error:", error);

View File

@ -145,14 +145,15 @@ export class WalletManager {
signature flow in time. */
this._wallets = parsed
.filter((w) => w && typeof w === 'object' && w.address && w.chain)
.map((w) => ({
address: String(w.address),
chain: String(w.chain).toLowerCase(),
nickname: w.nickname ? String(w.nickname) : '',
isVerified: !!(w.isVerified && w.messageData),
signature: w.signature ? String(w.signature) : null,
messageData: w.messageData ? w.messageData : null,
}))
.map((w) => ({
address: String(w.address),
chain: String(w.chain).toLowerCase(),
nickname: w.nickname ? String(w.nickname) : '',
lendingPlatform: w.lendingPlatform ? String(w.lendingPlatform).toLowerCase() : 'aave',
isVerified: !!(w.isVerified && w.messageData),
signature: w.signature ? String(w.signature) : null,
messageData: w.messageData ? w.messageData : null,
}))
.filter((w) => {
/* Non-verification chains (btc, bitcoin, solana) are always kept */
if (['btc', 'bitcoin', 'solana'].includes(w.chain)) return true;
@ -196,15 +197,16 @@ export class WalletManager {
}).map((w) => ({ ...w }));
}
/**
/**
* Add a new wallet. Rejects duplicates (same address+chain).
*
* @param {string} address
* @param {ChainId} chain
* @param {string} [nickname='']
* @param {string} [lendingPlatform='aave']
* @returns {{ success: true, wallet: TrackedWallet } | { success: false, error: string }}
*/
addWallet(address, chain, nickname = '') {
addWallet(address, chain, nickname = '', lendingPlatform = 'aave') {
const normalAddress = String(address).trim();
const normalChain = String(chain).toLowerCase();
const normalNickname = String(nickname).trim();
@ -236,6 +238,7 @@ export class WalletManager {
address: normalAddress,
chain: normalChain,
nickname: normalNickname,
lendingPlatform: String(lendingPlatform).toLowerCase(),
isVerified: false,
signature: null,
messageData: null,
@ -358,6 +361,26 @@ export class WalletManager {
this._persist();
return { success: true };
}
/**
* Reorder wallets to match the given address sequence.
* Addresses listed first are moved to front; remaining keep their original relative order.
* Only recognized addresses are kept in the new order.
* @param {string[]} addresses
*/
setWalletOrder(addresses) {
const addrSet = this._wallets.map(w => w.address);
const ordered = [];
for (const addr of addresses) {
if (addrSet.includes(addr)) {
const w = this._wallets.find(x => x.address === addr);
if (w) ordered.push(w);
}
}
const remaining = this._wallets.filter(w => !ordered.includes(w));
this._wallets = [...ordered, ...remaining];
this._persist();
}
}
export default WalletManager;