Compare commits
10 Commits
b659d26ad1
...
d96e56bde5
| Author | SHA1 | Date | |
|---|---|---|---|
| d96e56bde5 | |||
| b52da96ba0 | |||
| 8606673928 | |||
| 80a7449688 | |||
| 7a34190956 | |||
| 4f5a28ae84 | |||
| fab41179d0 | |||
| e163f83963 | |||
| 88c8f7927d | |||
| e28e66b29f |
16
AGENTS.md
16
AGENTS.md
@ -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.
|
||||
|
||||
647
index.html
647
index.html
@ -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);
|
||||
|
||||
43
wallets.js
43
wallets.js
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user