From 4f5a28ae841a09826a326edc223753107eab3dad Mon Sep 17 00:00:00 2001 From: Dione Date: Thu, 11 Jun 2026 06:21:42 +0000 Subject: [PATCH] fix: forward-fill wallet balances in chart aggregation for multi-wallet continuity --- index.html | 179 ++++++++++++++++++++++++++++++++++------------------- 1 file changed, 117 insertions(+), 62 deletions(-) diff --git a/index.html b/index.html index ec2f668..1d5b19a 100644 --- a/index.html +++ b/index.html @@ -861,7 +861,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 +948,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); @@ -1047,8 +1061,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 +1075,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 +1093,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 +1145,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); } @@ -1217,31 +1240,37 @@ 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. + Forward-fills each wallet's balance for gap days so multi-wallet + aggregation is continuous. */ 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; - }); - const byDate = new Map(); - sorted.forEach(d => { - if (!byDate.has(d.dateStr)) { - byDate.set(d.dateStr, d); + const sortedDates = Object.keys(allDates).sort(); + + const dayMap = {}; + addresses.forEach(addr => { + const snaps = addressSnapshots[addr] || []; + const dated = snaps.map(s => ({ + dateStr: s.block_timestamp.slice(0, 10), + btcAmt: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC') + })).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.btcAmt; + if (!dayMap[ds]) dayMap[ds] = 0; + dayMap[ds] += balance; } }); - return Array.from(byDate.values()).map(d => [new Date(d.dateStr).getTime(), d.total]); + + return sortedDates.map(dateStr => [new Date(dateStr).getTime(), dayMap[dateStr]]); } function calculateAggregatedSeries() { @@ -1487,7 +1516,7 @@ 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) { @@ -1577,38 +1606,48 @@ 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(); + + /* Per-wallet: forward-fill balance across all dates */ + const dailyBalances = {}; + selectedAddr.forEach(addr => { + const snaps = addressSnapshots[addr] || []; + const dated = snaps.map(s => ({ + dateStr: s.block_timestamp.slice(0, 10), + btcVal: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(s?.collateral?.cbBTC, 'cbBTC') + })).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; + if (!dailyBalances[ds]) dailyBalances[ds] = 0; + dailyBalances[ds] += balance; + } }); /* 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; + let prevTotal = 0; + sortedDates.forEach(ds => { + dailyDeltas[ds] = dailyBalances[ds] - prevTotal; + prevTotal = dailyBalances[ds]; }); /* Compute total sats acquired and days elapsed since first snapshot */ - const firstUTC = sortedDays.length > 0 ? Date.UTC(new Date(sortedDays[0].dateStr).getUTCFullYear(), new Date(sortedDays[0].dateStr).getUTCMonth(), new Date(sortedDays[0].dateStr).getUTCDate()) : Date.now(); - const lastUTC = sortedDays.length > 0 ? 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()) : Date.now(); - const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS)); - const totalBtc = sortedDays[sortedDays.length - 1]?.btcVal || 0; - const totalSats = Math.round(totalBtc * 1e8); - const avgSatsPerDay = Math.round(totalSats / daysElapsed); + 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)); + const totalBtc = sortedDates.length > 0 ? dailyBalances[sortedDates[sortedDates.length - 1]] : 0; + const totalSats = Math.round(totalBtc * 1e8); + const avgSatsPerDay = Math.round(totalSats / daysElapsed); /* Build continuous daily series: one data point per day */ const filteredSats = []; @@ -1643,23 +1682,39 @@ 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); - const totalSeries = entries.map(ts => { - return [ts, dayMap[ts].cold + dayMap[ts].collateral]; + const sortedDates = Object.keys(allDates).sort(); + + const dayMap = {}; + selectedAddr.forEach(addr => { + const snaps = addressSnapshots[addr] || []; + const dated = snaps.map(s => ({ + dateStr: s.block_timestamp.slice(0, 10), + cold: getTokenAmount(s?.wallet?.cbBTC, 'cbBTC'), + collateral: getTokenAmount(s?.collateral?.cbBTC, 'cbBTC') + })).sort((a, b) => a.dateStr.localeCompare(b.dateStr)); + + let cold = 0, coll = 0; + for (const ds of sortedDates) { + const match = dated.find(d => d.dateStr === ds); + if (match) { cold = match.cold; coll = match.collateral; } + if (!dayMap[ds]) dayMap[ds] = { cold: 0, collateral: 0 }; + dayMap[ds].cold += cold; + dayMap[ds].collateral += coll; + } + }); + + const entries = sortedDates; + const totalSeries = entries.map(dateStr => { + return [new Date(dateStr).getTime(), dayMap[dateStr].cold + dayMap[dateStr].collateral]; }).filter(d => d[0] >= cutoff); - const aaveSeries = entries.map(ts => { - return [ts, dayMap[ts].collateral]; + const aaveSeries = entries.map(dateStr => { + return [new Date(dateStr).getTime(), dayMap[dateStr].collateral]; }).filter(d => d[0] >= cutoff); const opts = { @@ -1730,7 +1785,7 @@ document.querySelectorAll('.filter-checkbox').forEach(cb => { =================================================================== */ async function pollSyncStatus() { const selectedAddr = getSelectedAddresses(); - const syncing = selectedAddr.filter(a => walletSyncState[a] === 'syncing'); + const syncing = selectedAddr.filter(a => walletSyncState[a] === 'syncing' || walletSyncState[a] === 'pending'); if (syncing.length === 0) return; const promises = syncing.map(async (addr) => { try {