fix: forward-fill wallet balances in chart aggregation for multi-wallet continuity

This commit is contained in:
Dione
2026-06-11 06:21:42 +00:00
parent fab41179d0
commit 4f5a28ae84

View File

@ -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 {