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.
|
- 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`.
|
- `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
|
## Coding Guidelines
|
||||||
|
|
||||||
You are an expert senior Web3 frontend engineer specializing in high-scale performance and stateless cryptographic security.
|
You are an expert senior Web3 frontend engineer specializing in high-scale performance and stateless cryptographic security.
|
||||||
|
|||||||
609
index.html
609
index.html
@ -109,6 +109,17 @@ window.WalletManager = WalletManager;
|
|||||||
::-webkit-scrollbar-track { background: #05070B; }
|
::-webkit-scrollbar-track { background: #05070B; }
|
||||||
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
|
::-webkit-scrollbar-thumb { background: #1F2937; border-radius: 4px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: #374151; }
|
::-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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="p-4 md:p-8 min-h-screen text-white">
|
<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>
|
<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>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<span class="text-lg font-bold" style="color: #FF7A00;">30D AVG</span>
|
<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">Daily Acquisition</p>
|
<p class="text-xs text-gray-500 font-medium mt-0.5 uppercase tracking-tighter">Sats per Day</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full h-52 mt-4" id="chart-container-sats"></div>
|
<div class="w-full h-52 mt-4" id="chart-container-sats"></div>
|
||||||
@ -393,6 +404,19 @@ window.WalletManager = WalletManager;
|
|||||||
this._persist();
|
this._persist();
|
||||||
return { success: true };
|
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 blueBrandColor = '#3b82f6';
|
||||||
const API_BASE = window.location.origin;
|
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 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
|
App State
|
||||||
@ -413,6 +437,7 @@ const wm = new WalletManager();
|
|||||||
wm.loadWallets();
|
wm.loadWallets();
|
||||||
|
|
||||||
let currentAggregatedSeries = [];
|
let currentAggregatedSeries = [];
|
||||||
|
let currentWalletSeries = [];
|
||||||
let currentNetHeld = 0;
|
let currentNetHeld = 0;
|
||||||
let currentBuyCost = 0;
|
let currentBuyCost = 0;
|
||||||
let currentBuyAmount = 0;
|
let currentBuyAmount = 0;
|
||||||
@ -861,7 +886,21 @@ async function handleAddWallet(e) {
|
|||||||
closeSidebar();
|
closeSidebar();
|
||||||
walletSyncState[address] = 'syncing';
|
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();
|
renderSidebar();
|
||||||
renderWalletPills();
|
renderWalletPills();
|
||||||
renderLedgerFilterWallets();
|
renderLedgerFilterWallets();
|
||||||
@ -934,7 +973,7 @@ async function handleVerifyWallet(address, chain, nickname) {
|
|||||||
const result = await verifyOwnership(address, chain, nickname || w.nickname);
|
const result = await verifyOwnership(address, chain, nickname || w.nickname);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
/* Fetch data now that wallet is verified */
|
/* Fetch data now that wallet is verified */
|
||||||
if (chain === 'base') fetchWalletAaveData(address);
|
fetchWalletAaveData(address, chain);
|
||||||
} else {
|
} else {
|
||||||
/* Remove unverified wallet on failure */
|
/* Remove unverified wallet on failure */
|
||||||
wm.removeWallet(address, chain);
|
wm.removeWallet(address, chain);
|
||||||
@ -992,8 +1031,8 @@ function renderWalletPills() {
|
|||||||
const nick = w.nickname || 'Wallet';
|
const nick = w.nickname || 'Wallet';
|
||||||
const isActive = isWalletInLedger(w.address);
|
const isActive = isWalletInLedger(w.address);
|
||||||
const platformBadge = getPlatformBadge(w.lendingPlatform);
|
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 + '">' +
|
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="event.stopPropagation();toggleWalletPill(\'' + w.address + '\')">' +
|
'<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">' +
|
'<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-[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>' +
|
'<span class="text-[8px] font-medium text-gray-600 uppercase tracking-tighter leading-tight">' + w.chain + '</span>' +
|
||||||
@ -1001,6 +1040,7 @@ function renderWalletPills() {
|
|||||||
'</label>';
|
'</label>';
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
setupPillDragListeners(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleWalletPill(address) {
|
function toggleWalletPill(address) {
|
||||||
@ -1008,6 +1048,117 @@ function toggleWalletPill(address) {
|
|||||||
renderLedgerFilterWallets();
|
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() {
|
function getSelectedAddresses() {
|
||||||
const unchecked = getWalletFilter();
|
const unchecked = getWalletFilter();
|
||||||
const wallets = wm.getWallets();
|
const wallets = wm.getWallets();
|
||||||
@ -1047,8 +1198,9 @@ function renderLedgerFilterWallets() {
|
|||||||
/* ===================================================================
|
/* ===================================================================
|
||||||
Dynamic Data Fetching (Promise.all aggregation)
|
Dynamic Data Fetching (Promise.all aggregation)
|
||||||
=================================================================== */
|
=================================================================== */
|
||||||
async function fetchWalletAaveData(address) {
|
async function fetchWalletAaveData(address, chain) {
|
||||||
const endpoint = `/api/v1/portfolio/${address}/base/aave`;
|
chain = chain || 'base';
|
||||||
|
const endpoint = `/api/v1/portfolio/${address}/${chain}/aave`;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${API_BASE}${endpoint}`);
|
const resp = await fetch(`${API_BASE}${endpoint}`);
|
||||||
if (resp.status === 400) {
|
if (resp.status === 400) {
|
||||||
@ -1060,6 +1212,11 @@ async function fetchWalletAaveData(address) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (resp.status === 404) {
|
||||||
|
walletSyncState[address] = 'pending';
|
||||||
|
renderWalletPills();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
const events = await resp.json();
|
const events = await resp.json();
|
||||||
walletSyncState[address] = 'synced';
|
walletSyncState[address] = 'synced';
|
||||||
@ -1073,11 +1230,12 @@ async function fetchWalletAaveData(address) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Poll until wallet data is synchronized, up to ~60s */
|
/* 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;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
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) {
|
if (resp.ok) {
|
||||||
const events = await resp.json();
|
const events = await resp.json();
|
||||||
if (Array.isArray(events) && events.length > 0) {
|
if (Array.isArray(events) && events.length > 0) {
|
||||||
@ -1124,10 +1282,12 @@ async function fetchSelectedWalletsData() {
|
|||||||
/* Only fetch for wallets that are synced or syncing */
|
/* Only fetch for wallets that are synced or syncing */
|
||||||
const promises = selectedAddr.map(async (addr) => {
|
const promises = selectedAddr.map(async (addr) => {
|
||||||
const sync = walletSyncState[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 === 'pending') return null;
|
||||||
if (sync === 'synced' && addressSnapshots[addr]) return null; /* Cached */
|
if (sync === 'synced' && addressSnapshots[addr]) return null;
|
||||||
await fetchWalletAaveData(addr);
|
const w = wm.getWallets().find(w => w.address === addr);
|
||||||
|
const chain = (w || {}).chain || 'base';
|
||||||
|
await fetchWalletAaveData(addr, chain);
|
||||||
});
|
});
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
}
|
}
|
||||||
@ -1140,6 +1300,15 @@ function getTokenAmount(raw, symbol) {
|
|||||||
return parseFloat(raw || '0') / Math.pow(10, dec);
|
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) {
|
function priceForToken(symbol, dateStr) {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
|
const ps = TOKENS[symbol] ? TOKENS[symbol].priceSymbol : symbol;
|
||||||
@ -1217,30 +1386,56 @@ function getOldestTransactionDate() {
|
|||||||
return oldestTs === Infinity ? Date.now() - 365 * 86400000 : oldestTs;
|
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) {
|
function buildCumulativeSeries(addresses) {
|
||||||
const dayMap = {};
|
const allDates = {};
|
||||||
addresses.forEach(addr => {
|
addresses.forEach(addr => {
|
||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
snaps.forEach(snap => {
|
snaps.forEach(snap => {
|
||||||
const ts = new Date(snap.block_timestamp).getTime();
|
allDates[snap.block_timestamp.slice(0, 10)] = true;
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const sorted = Object.values(dayMap).sort((a, b) => {
|
const snapshotDates = Object.keys(allDates).sort();
|
||||||
const tsA = new Date(a.dateStr).getTime();
|
if (snapshotDates.length === 0) return { total: [], perWallet: [] };
|
||||||
const tsB = new Date(b.dateStr).getTime();
|
|
||||||
return tsA - tsB;
|
/* 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));
|
||||||
});
|
});
|
||||||
let cumul = 0;
|
|
||||||
return sorted.map(d => {
|
/* Walk every calendar day, forward-fill each wallet, aggregate */
|
||||||
cumul += d.total;
|
const totalResult = [];
|
||||||
const dateStr = d.dateStr;
|
const perWalletResult = {};
|
||||||
return [new Date(dateStr).getTime(), cumul];
|
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() {
|
function calculateAggregatedSeries() {
|
||||||
@ -1250,10 +1445,13 @@ function calculateAggregatedSeries() {
|
|||||||
|
|
||||||
if (selectedAddr.length === 0) {
|
if (selectedAddr.length === 0) {
|
||||||
currentAggregatedSeries = [];
|
currentAggregatedSeries = [];
|
||||||
|
currentWalletSeries = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAggregatedSeries = buildCumulativeSeries(selectedAddr);
|
const result = buildCumulativeSeries(selectedAddr);
|
||||||
|
currentAggregatedSeries = result.total;
|
||||||
|
currentWalletSeries = result.perWallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateCurrentHoldings() {
|
function calculateCurrentHoldings() {
|
||||||
@ -1268,22 +1466,22 @@ function calculateCurrentHoldings() {
|
|||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
if (snaps.length > 0) {
|
if (snaps.length > 0) {
|
||||||
const latest = snaps[0];
|
const latest = snaps[0];
|
||||||
currentNetHeld += getTokenAmount(latest?.wallet?.cbBTC, 'cbBTC');
|
currentNetHeld += getTotalBTC(latest?.wallet);
|
||||||
currentNetHeld += getTokenAmount(latest?.collateral?.cbBTC, 'cbBTC');
|
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 => {
|
selectedAddr.forEach(addr => {
|
||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
if (snaps.length < 2) return;
|
if (snaps.length === 0) return;
|
||||||
let prevBtc = 0;
|
let prevBtc = 0;
|
||||||
snaps.forEach(snap => {
|
snaps.slice().reverse().forEach(snap => {
|
||||||
const currentDate = snap.block_timestamp.slice(0, 10);
|
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;
|
const delta = currentBtc - prevBtc;
|
||||||
if (delta > 0) {
|
if (delta > 0) {
|
||||||
const price = priceForToken('cbBTC', currentDate) || 0;
|
const price = priceForToken('BTC', currentDate) || 0;
|
||||||
if (price > 0) currentBuyCost += delta * price;
|
if (price > 0) currentBuyCost += delta * price;
|
||||||
currentBuyAmount += delta;
|
currentBuyAmount += delta;
|
||||||
}
|
}
|
||||||
@ -1297,7 +1495,9 @@ function updateDashboard() {
|
|||||||
calculateCurrentHoldings();
|
calculateCurrentHoldings();
|
||||||
|
|
||||||
if (window.cumulChart) {
|
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);
|
document.getElementById('net-held-val').innerText = currentNetHeld.toFixed(6);
|
||||||
@ -1486,11 +1686,16 @@ function renderCombinedTable() {
|
|||||||
=================================================================== */
|
=================================================================== */
|
||||||
async function fetchAllWalletData() {
|
async function fetchAllWalletData() {
|
||||||
const verifiedWallets = wm.getVerifiedWallets() || [];
|
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);
|
await Promise.all(promises);
|
||||||
const allSnaps = Object.values(addressSnapshots).flat();
|
const allSnaps = Object.values(addressSnapshots).flat();
|
||||||
if (allSnaps.length > 0) {
|
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);
|
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 }] } },
|
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 } },
|
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||||||
markers: { size: 0, hover: { size: 5 } },
|
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 } },
|
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>'; } },
|
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 } }
|
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) {
|
function setupCumulCard(cutoff) {
|
||||||
const filteredData = currentAggregatedSeries.filter(d => d[0] >= cutoff);
|
const selectedAddr = getSelectedAddresses();
|
||||||
const options = getBaseChartOptions('instance-cumul', filteredData, orangeBrandColor, false);
|
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);
|
const chart = new ApexCharts(document.querySelector("#chart-container-cumul"), options);
|
||||||
chart.render();
|
chart.render();
|
||||||
window.cumulChart = chart;
|
window.cumulChart = chart;
|
||||||
@ -1571,65 +1835,113 @@ function setupCumulCard(cutoff) {
|
|||||||
|
|
||||||
function setupSatsCard(cutoff) {
|
function setupSatsCard(cutoff) {
|
||||||
const DAY_MS = 86400000;
|
const DAY_MS = 86400000;
|
||||||
/* Derive daily balances (latest snapshot per day) using UTC date strings */
|
/* Collect all dates across selected wallets, then forward-fill each wallet's balance */
|
||||||
const dailyBalances = {};
|
const allDates = {};
|
||||||
const selectedAddr = getSelectedAddresses() || [];
|
const selectedAddr = getSelectedAddresses() || [];
|
||||||
selectedAddr.forEach(addr => {
|
selectedAddr.forEach(addr => {
|
||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
snaps.forEach(snap => {
|
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
|
||||||
const utcTs = new Date(snap.block_timestamp).getTime();
|
});
|
||||||
const dateStr = new Date(utcTs).toISOString().slice(0, 10);
|
const sortedDates = Object.keys(allDates).sort();
|
||||||
const btcVal = getTokenAmount(snap?.wallet?.cbBTC, 'cbBTC') + getTokenAmount(snap?.collateral?.cbBTC, 'cbBTC');
|
|
||||||
if (!dailyBalances[dateStr] || utcTs > dailyBalances[dateStr].ts) {
|
const wallets = wm.getWallets();
|
||||||
dailyBalances[dateStr] = { ts: utcTs, dateStr, btcVal };
|
/* Per-wallet forward-fill */
|
||||||
} else {
|
const walletDailyBalances = {};
|
||||||
dailyBalances[dateStr].btcVal += btcVal;
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Sort by date, compute daily delta (acquisition) */
|
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 sortedDays = Object.values(dailyBalances).sort((a, b) => a.dateStr.localeCompare(b.dateStr));
|
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 dailyDeltas = {};
|
const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS));
|
||||||
sortedDays.forEach((d, i) => {
|
|
||||||
const prevBalance = i > 0 ? sortedDays[i - 1].btcVal : 0;
|
|
||||||
dailyDeltas[d.dateStr] = d.btcVal - prevBalance;
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Fill continuous daily timeline — iterate every day from first to last */
|
/* Per-wallet series: running avg sats/day */
|
||||||
const firstUTC = Date.UTC(new Date(sortedDays[0].dateStr).getUTCFullYear(), new Date(sortedDays[0].dateStr).getUTCMonth(), new Date(sortedDays[0].dateStr).getUTCDate());
|
const series = selectedAddr.map(addr => {
|
||||||
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 w = wallets.find(x => x.address === addr);
|
||||||
const continuousDays = [];
|
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) {
|
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
|
||||||
const ds = new Date(t).toISOString().slice(0, 10);
|
const ds = new Date(t).toISOString().slice(0, 10);
|
||||||
continuousDays.push({ ts: t, dateStr: ds, delta: dailyDeltas[ds] || 0 });
|
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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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)]);
|
|
||||||
}
|
}
|
||||||
|
return { name: nickname, data };
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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 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 = {
|
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 } },
|
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 },
|
||||||
series: [{ name: 'Avg Sats/Day', data: emaSats }],
|
stackType: 'normal',
|
||||||
|
series: series,
|
||||||
dataLabels: { enabled: false },
|
dataLabels: { enabled: false },
|
||||||
colors: [orangeBrandColor],
|
colors: colors,
|
||||||
stroke: { curve: 'smooth', width: 2 },
|
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 } },
|
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||||||
markers: { size: 0, hover: { size: 5 } },
|
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 } },
|
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 } }
|
crosshairs: { show: true, width: 1, position: 'back', stroke: { color: '#4B5563', width: 1, dashArray: 3 } }
|
||||||
};
|
};
|
||||||
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
|
const chart = new ApexCharts(document.querySelector("#chart-container-sats"), options);
|
||||||
@ -1639,28 +1951,61 @@ function setupSatsCard(cutoff) {
|
|||||||
|
|
||||||
function setupBreakdownCard(cutoff) {
|
function setupBreakdownCard(cutoff) {
|
||||||
/* Derive from snapshots: cold wallet vs collateral */
|
/* Derive from snapshots: cold wallet vs collateral */
|
||||||
const dayMap = {};
|
const allDates = {};
|
||||||
const selectedAddr = getSelectedAddresses() || [];
|
const selectedAddr = getSelectedAddresses() || [];
|
||||||
selectedAddr.forEach(addr => {
|
selectedAddr.forEach(addr => {
|
||||||
const snaps = addressSnapshots[addr] || [];
|
const snaps = addressSnapshots[addr] || [];
|
||||||
snaps.forEach(snap => {
|
snaps.forEach(snap => { allDates[snap.block_timestamp.slice(0, 10)] = true; });
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
const entries = Object.keys(dayMap).map(ts => parseInt(ts)).sort((a, b) => a - b);
|
|
||||||
let c0 = 0, c1 = 0;
|
/* Walk every calendar day, forward-fill each wallet, aggregate */
|
||||||
const totalSeries = entries.map(ts => {
|
const totalSeries = [];
|
||||||
c0 += dayMap[ts].cold + dayMap[ts].collateral;
|
const aaveSeries = [];
|
||||||
return [ts, c0];
|
for (let t = firstUTC; t <= lastUTC; t += DAY_MS) {
|
||||||
}).filter(d => d[0] >= cutoff);
|
const ds = new Date(t).toISOString().slice(0, 10);
|
||||||
let c2 = 0;
|
let dayCold = 0, dayColl = 0;
|
||||||
const aaveSeries = entries.map(ts => {
|
selectedAddr.forEach(addr => {
|
||||||
c2 += dayMap[ts].collateral;
|
const dated = walletDated[addr];
|
||||||
return [ts, c2];
|
let c = 0, co = 0;
|
||||||
}).filter(d => d[0] >= cutoff);
|
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 = {
|
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 } },
|
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 }]] } },
|
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 } },
|
grid: { show: false, padding: { left: 0, right: 0, top: 0, bottom: 0 } },
|
||||||
markers: { size: 0, strokeColors: [orangeBrandColor, blueBrandColor], strokeWidth: 3, hover: { size: 5 } },
|
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 } },
|
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>'; } },
|
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 } },
|
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 } },
|
||||||
@ -1702,6 +2047,22 @@ function startTickUpdates(data) {
|
|||||||
}, 2500);
|
}, 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
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
|
Global Event Listeners
|
||||||
=================================================================== */
|
=================================================================== */
|
||||||
@ -1709,44 +2070,6 @@ document.querySelectorAll('.filter-checkbox').forEach(cb => {
|
|||||||
cb.addEventListener('change', () => updateDashboard());
|
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
|
Polling for Verified Wallets
|
||||||
=================================================================== */
|
=================================================================== */
|
||||||
@ -1757,7 +2080,7 @@ async function pollVerifiedWallets() {
|
|||||||
const verified = wm.getVerifiedWallets() || [];
|
const verified = wm.getVerifiedWallets() || [];
|
||||||
for (const w of verified) {
|
for (const w of verified) {
|
||||||
try {
|
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;
|
if (!resp.ok) continue;
|
||||||
const events = await resp.json();
|
const events = await resp.json();
|
||||||
const newestTs = events[events.length - 1].block_timestamp;
|
const newestTs = events[events.length - 1].block_timestamp;
|
||||||
@ -1801,17 +2124,15 @@ async function initDashboardGrid() {
|
|||||||
const rawPrices = result.result.XXBTZUSD;
|
const rawPrices = result.result.XXBTZUSD;
|
||||||
btcPriceData = rawPrices.map(item => [item[0] * 1000, parseFloat(item[4])]);
|
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 fetchAllWalletData();
|
||||||
|
|
||||||
await refreshPrices();
|
|
||||||
renderCombinedTable();
|
renderCombinedTable();
|
||||||
|
|
||||||
const cutoff = getOldestTransactionDate();
|
const cutoff = getOldestTransactionDate();
|
||||||
currentCutoffMs = cutoff;
|
currentCutoffMs = cutoff;
|
||||||
refreshAllCharts(cutoff);
|
refreshAllCharts(cutoff);
|
||||||
|
|
||||||
startSyncPolling();
|
|
||||||
startPolling();
|
startPolling();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Dashboard error:", error);
|
console.error("Dashboard error:", error);
|
||||||
|
|||||||
25
wallets.js
25
wallets.js
@ -149,6 +149,7 @@ export class WalletManager {
|
|||||||
address: String(w.address),
|
address: String(w.address),
|
||||||
chain: String(w.chain).toLowerCase(),
|
chain: String(w.chain).toLowerCase(),
|
||||||
nickname: w.nickname ? String(w.nickname) : '',
|
nickname: w.nickname ? String(w.nickname) : '',
|
||||||
|
lendingPlatform: w.lendingPlatform ? String(w.lendingPlatform).toLowerCase() : 'aave',
|
||||||
isVerified: !!(w.isVerified && w.messageData),
|
isVerified: !!(w.isVerified && w.messageData),
|
||||||
signature: w.signature ? String(w.signature) : null,
|
signature: w.signature ? String(w.signature) : null,
|
||||||
messageData: w.messageData ? w.messageData : null,
|
messageData: w.messageData ? w.messageData : null,
|
||||||
@ -202,9 +203,10 @@ export class WalletManager {
|
|||||||
* @param {string} address
|
* @param {string} address
|
||||||
* @param {ChainId} chain
|
* @param {ChainId} chain
|
||||||
* @param {string} [nickname='']
|
* @param {string} [nickname='']
|
||||||
|
* @param {string} [lendingPlatform='aave']
|
||||||
* @returns {{ success: true, wallet: TrackedWallet } | { success: false, error: string }}
|
* @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 normalAddress = String(address).trim();
|
||||||
const normalChain = String(chain).toLowerCase();
|
const normalChain = String(chain).toLowerCase();
|
||||||
const normalNickname = String(nickname).trim();
|
const normalNickname = String(nickname).trim();
|
||||||
@ -236,6 +238,7 @@ export class WalletManager {
|
|||||||
address: normalAddress,
|
address: normalAddress,
|
||||||
chain: normalChain,
|
chain: normalChain,
|
||||||
nickname: normalNickname,
|
nickname: normalNickname,
|
||||||
|
lendingPlatform: String(lendingPlatform).toLowerCase(),
|
||||||
isVerified: false,
|
isVerified: false,
|
||||||
signature: null,
|
signature: null,
|
||||||
messageData: null,
|
messageData: null,
|
||||||
@ -358,6 +361,26 @@ export class WalletManager {
|
|||||||
this._persist();
|
this._persist();
|
||||||
return { success: true };
|
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;
|
export default WalletManager;
|
||||||
|
|||||||
Reference in New Issue
Block a user