From 11c292eb9069195ed2fa49bde620af94c0463fc5 Mon Sep 17 00:00:00 2001 From: Dione Date: Sat, 13 Jun 2026 06:41:57 +0000 Subject: [PATCH] fix: extend chart x-axes to current date for cumul, sats, and breakdown charts --- index.html | 536 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 528 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index a32760f..f599b2d 100644 --- a/index.html +++ b/index.html @@ -120,6 +120,28 @@ window.WalletManager = WalletManager; border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px #FF7A00; position: absolute; left: -3px; top: 0; } + + /* Toast notifications */ + .toast-box { + background: #090D14; border: 1px solid #1A1F2C; border-radius: 10px; + padding: 12px 18px; min-width: 280px; box-shadow: 0 12px 32px rgba(0,0,0,0.7); + display: flex; align-items: flex-start; gap: 10px; + animation: toast-in 0.3s ease-out; + } + .toast-box.toast-exit { animation: toast-out 0.25s ease-in forwards; } + @keyframes toast-in { from { opacity: 0; transform: translateY(12px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } } + @keyframes toast-out { to { opacity: 0; transform: translateY(8px) scale(0.95); } } + .toast-icon { font-size: 16px; flex-shrink: 0; margin-top: 1px; } + .toast-body { flex: 1; } + .toast-title { font-size: 13px; font-weight: 700; color: #fff; margin-bottom: 2px; } + .toast-message { font-size: 12px; color: #9CA3AF; line-height: 1.4; } + .toast-progress { height: 3px; background: #1F2937; border-radius: 2px; margin-top: 8px; overflow: hidden; } + .toast-progress-bar { height: 100%; border-radius: 2px; animation: toast-progress-shrink 4s linear forwards; } + @keyframes toast-progress-shrink { to { width: 0%; } } + + /* Upload button spinner */ + .import-btn-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,122,0,0.2); border-top-color: #FF7A00; border-radius: 50%; animation: spin 0.6s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } @@ -134,11 +156,15 @@ window.WalletManager = WalletManager; -
+
+
@@ -180,6 +206,53 @@ window.WalletManager = WalletManager;
+ + + + +
+ + + +
@@ -456,6 +529,11 @@ const walletSyncState = {}; /* Per-address snapshot data, keyed by address */ const addressSnapshots = {}; + /* Cached CSV import context: parsed records, selected address, chain */ +let _csvImportRecords = []; + +/* Assigned color index for dynamic wallets */ + /* Assigned color index for dynamic wallets */ let _colorIdx = 0; function getColorForWallet(address) { @@ -625,7 +703,7 @@ function renderSidebar() { const verifyBtn = isEVM && !isVerified ? '' : ''; - html += '
' + + html += '
' + '
' + '
' + '
' + @@ -1774,10 +1852,16 @@ function setupCumulCard(cutoff) { 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 filtered = ws.filter(d => d[0] >= cutoff); + /* Extend to today */ + if (filtered.length > 0) { + const lastPt = filtered[filtered.length - 1]; + const todayUTC = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); + if (lastPt[0] < todayUTC) { + filtered.push([todayUTC, lastPt[1]]); + } + } + return { name: nickname, data: filtered }; }); const colors = selectedAddr.map(addr => getColorForWallet(addr)); @@ -1874,8 +1958,9 @@ function setupSatsCard(cutoff) { } }); + const todayUTCs = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); 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 lastUTC = sortedDates.length > 0 ? Math.max(Date.UTC(new Date(sortedDates[sortedDates.length - 1]).getUTCFullYear(), new Date(sortedDates[sortedDates.length - 1]).getUTCMonth(), new Date(sortedDates[sortedDates.length - 1]).getUTCDate()), todayUTCs) : Date.now(); const daysElapsed = Math.max(1, Math.ceil((lastUTC - firstUTC) / DAY_MS)); /* Per-wallet series: running avg sats/day */ @@ -1970,8 +2055,9 @@ function setupBreakdownCard(cutoff) { return; } + const todayUTCb = Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), new Date().getUTCDate()); 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 lastUTC = Math.max(Date.UTC(new Date(snapshotDates[snapshotDates.length - 1]).getUTCFullYear(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCMonth(), new Date(snapshotDates[snapshotDates.length - 1]).getUTCDate()), todayUTCb); const DAY_MS = 86400000; /* Pre-compute per-wallet dated arrays (oldest-first) */ @@ -2140,6 +2226,440 @@ async function initDashboardGrid() { } initDashboardGrid(); + +/* =================================================================== + Toast Notification System + =================================================================== */ +function showToast(title, message, type = 'info', duration = 4000) { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = 'toast-box'; + + let icon = 'ℹ'; + let barColor = '#5B7FFF'; + if (type === 'success') { icon = '✓'; barColor = '#22c55e'; } + else if (type === 'error') { icon = '✕'; barColor = '#ef4444'; } + else if (type === 'warning') { icon = '⚠'; barColor = '#eab308'; } + + toast.innerHTML = + '
' + icon + '
' + + '
' + + '
' + title + '
' + + '
' + message + '
' + + '
' + + '
'; + + container.appendChild(toast); + + setTimeout(() => { + toast.classList.add('toast-exit'); + setTimeout(() => toast.remove(), 260); + }, duration); +} + +/* =================================================================== + CSV Import — Modal + Form Flow + =================================================================== */ +/* Stash selected file while modal is open */ +let _importCsvFile = null; +let _importSelectedColor = null; + +function renderImportColorPicker() { + const container = document.getElementById('import-color-picker'); + container.innerHTML = ''; + if (!_importSelectedColor) _importSelectedColor = autoPickColor(); + WALLET_COLORS.forEach((c) => { + const swatch = document.createElement('div'); + swatch.className = 'color-picker-swatch' + (_importSelectedColor.toLowerCase() === c.toLowerCase() ? ' selected' : ''); + swatch.style.setProperty('--swatch-color', c); + swatch.style.background = c; + swatch.addEventListener('click', () => { + _importSelectedColor = c; + renderImportColorPicker(); + }); + container.appendChild(swatch); + }); +} + +function openImportCsvModal() { + _importCsvFile = null; + _csvImportRecords = []; + _importSelectedColor = autoPickColor(); + document.getElementById('import-nickname').value = ''; + document.getElementById('import-address').value = ''; + document.getElementById('import-filename').textContent = 'Click to select a .csv file'; + document.getElementById('import-file-display').classList.remove('border-green-500/50'); + document.getElementById('import-file-display').classList.add('border-[#1A1F2C]'); + document.getElementById('import-csv-error').classList.add('hidden'); + document.getElementById('import-csv-progress').classList.add('hidden'); + document.getElementById('import-csv-submit').disabled = false; + document.getElementById('import-submit-text').textContent = 'Import CSV'; + renderImportColorPicker(); + document.getElementById('import-csv-modal').classList.add('open'); +} + +function closeImportCsvModal() { + document.getElementById('import-csv-modal').classList.remove('open'); + _importCsvFile = null; + _csvImportRecords = []; +} + +/* Called when hidden file input gets a file — store it, show name */ +function handleCsvFileSelect(event) { + const file = event.target.files[0]; + if (file) { + _importCsvFile = file; + document.getElementById('import-filename').textContent = file.name; + document.getElementById('import-file-display').classList.remove('border-[#1A1F2C]'); + document.getElementById('import-file-display').classList.add('border-green-500/50'); + } +} + +/* Parse the CSV on file select to validate early */ +function parseCsvOnSelect(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const records = parseCsvAndMap(e.target.result); + resolve(records); + } catch (err) { + reject(err); + } + }; + reader.onerror = reject; + reader.readAsText(file); + }); +} + +/* Submit handler */ +async function handleImportCsv(e) { + e.preventDefault(); + + const nickname = document.getElementById('import-nickname').value.trim(); + const addressInput = document.getElementById('import-address').value.trim(); + const chain = 'base'; + const color = _importSelectedColor || autoPickColor(); + const errEl = document.getElementById('import-csv-error'); + const progressEl = document.getElementById('import-csv-progress'); + const submitBtn = document.getElementById('import-csv-submit'); + const submitText = document.getElementById('import-submit-text'); + + /* Validate */ + if (!nickname) { + errEl.textContent = 'Nickname is required.'; + errEl.classList.remove('hidden'); + return false; + } + if (!_importCsvFile) { + errEl.textContent = 'No CSV file selected.'; + errEl.classList.remove('hidden'); + return false; + } + + errEl.classList.add('hidden'); + progressEl.classList.remove('hidden'); + submitBtn.disabled = true; + submitText.textContent = 'Processing...'; + + try { + /* Parse CSV */ + const records = await parseCsvOnSelect(_importCsvFile); + _csvImportRecords = records; + + if (records.length === 0) { + errEl.textContent = 'No valid records found in the CSV file.'; + errEl.classList.remove('hidden'); + progressEl.classList.add('hidden'); + submitBtn.disabled = false; + submitText.textContent = 'Import CSV'; + return false; + } + + /* Resolve address: use input if valid, otherwise generate */ + let address = addressInput.toLowerCase(); + let addressWasAutoGenerated = false; + + if (address && /^(0x)?[0-9a-f]{40}$/i.test(address)) { + if (!address.startsWith('0x')) address = '0x' + address; + /* Check duplicate */ + const existing = wm.findWallet(address, chain); + if (existing) { + errEl.textContent = `Wallet ${address} already tracked on ${chain}.`; + errEl.classList.remove('hidden'); + progressEl.classList.add('hidden'); + submitBtn.disabled = false; + submitText.textContent = 'Import CSV'; + return false; + } + } else { + /* Generate a synthetic address from nickname hash */ + let hashArr; + if (crypto?.subtle) { + const hashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(nickname + Date.now())); + hashArr = new Uint8Array(hashBuf); + } else { + /* Fallback for non-secure contexts where crypto.subtle is unavailable */ + let s = (nickname + Date.now()).split('').reduce((a, c) => ((a << 5) - a + c.charCodeAt(0)) | 0, 0); + hashArr = new Uint8Array(32); + for (let i = 0; i < 32; i++) { s = ((s ^ s >> 15) * 2654435761) | 0; hashArr[i] = s % 256; } + } + address = '0x' + Array.from(hashArr.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join(''); + document.getElementById('import-address').value = address; + addressWasAutoGenerated = true; + } + + submitText.textContent = 'Uploading...'; + + /* Register wallet in state without verification (non-EVM flow) */ + wm.addWallet(address, chain, nickname); + wm.verifyWallet(address, chain, '', { action: 'CSV Import (no-signature)', walletAddress: address }); + + /* POST to backend */ + const payload = { records: records.map(r => ({ ...r })) }; + + const resp = await fetch(`${API_BASE}/api/v1/portfolio/import/${chain}/${address}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!resp.ok) { + const errBody = await resp.json().catch(() => ({})); + throw new Error(errBody?.detail || `HTTP ${resp.status}`); + } + + const result = await resp.json(); + const inserted = result.inserted || 0; + const skipped = result.skipped || 0; + + /* Update frontend state */ + if (result.ledger && Array.isArray(result.ledger)) { + addressSnapshots[address] = snapshotsToDaily(result.ledger); + } else { + addressSnapshots[address] = snapshotsToDaily(records.map(r => ({ + block_timestamp: r.block_timestamp, + wallet: { [r.token_address]: r.amount }, + collateral: {}, + debt: {}, + tx_hash: r.tx_hash || null, + }))); + } + walletSyncState[address] = 'synced'; + + /* Assign color and mark synced */ + setColorForWallet(address, color); + walletSyncState[address] = 'synced'; + + showToast( + 'Import Complete', + `${inserted} record${inserted !== 1 ? 's' : ''} added` + (skipped > 0 ? `, ${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped` : ''), + 'success', + 5000 + ); + + closeImportCsvModal(); + closeSidebar(); + renderAll(); + + /* Re-fetch prices for new date range and re-render */ + await _fetchPricesAndRender(); + + } catch (err) { + showToast('Import Failed', err.message || 'Network error', 'error'); + progressEl.classList.add('hidden'); + submitBtn.disabled = false; + submitText.textContent = 'Import CSV'; + } + + return false; +} + +/* =================================================================== + CSV Parsing Engine — Zero Dependency + =================================================================== */ +/* Column header aliases → canonical snake_case field names */ +const CSV_FIELD_MAP = { + 'tx_hash': 'tx_hash', + 'transaction_hash': 'tx_hash', + 'transactionhash': 'tx_hash', + 'txhash': 'tx_hash', + 'block_number': 'block_number', + 'blocknumber': 'block_number', + 'blockno': 'block_number', + 'block_timestamp': 'block_timestamp', + 'blocktimestamp': 'block_timestamp', + 'timestamp': 'block_timestamp', + 'time': 'block_timestamp', + 'token_address': 'token_address', + 'tokenaddress': 'token_address', + 'token_contract': 'token_address', + 'contract_address': 'token_address', + 'amount': 'amount', + 'raw_amount': 'amount', + 'wei': 'amount', + 'direction': 'direction', + 'flow': 'direction', + 'type': 'direction', + 'from_address': 'from_address', + 'fromaddress': 'from_address', + 'from': 'from_address', + 'to_address': 'to_address', + 'toaddress': 'to_address', + 'to': 'to_address', +}; + +/* Canonical fields */ +const REQUIRED_CSV_FIELDS = ['block_timestamp', 'token_address', 'amount', 'direction']; + +/** + * Parse CSV text and map columns to ImportTransferRecord fields. + * Supports quoted fields, commas inside quotes, and CRLF/LF line endings. + */ +function parseCsvAndMap(csvText) { + /* Split into rows — respect quoted fields with embedded newlines */ + const rows = splitCsvRows(csvText); + if (rows.length < 2) return []; + + /* Parse header row → normalize to snake_case */ + const header = rows[0].map(h => { + const trimmed = h.trim().toLowerCase().replace(/\s+/g, '_').replace(/^-+|-+$/g, ''); + return CSV_FIELD_MAP[trimmed] || trimmed; + }); + + /* Validate required fields */ + const missing = REQUIRED_CSV_FIELDS.filter(f => !header.includes(f)); + if (missing.length > 0) { + showToast('Import Failed', `CSV missing required columns: ${missing.join(', ')}`, 'error'); + return []; + } + + const colIdx = {}; + header.forEach((f, i) => { colIdx[f] = i; }); + + const records = []; + + for (let r = 1; r < rows.length; r++) { + const row = rows[r]; + if (row.length < header.length) continue; /* skip malformed row */ + + /* Skip empty rows */ + if (row.every(c => c.trim() === '')) continue; + + const blockTimestamp = row[colIdx['block_timestamp']].trim(); + const tokenAddress = row[colIdx['token_address']].trim(); + const amountRaw = row[colIdx['amount']].trim(); + const directionRaw = row[colIdx['direction']].trim().toUpperCase(); + + /* Validate required fields */ + if (!blockTimestamp || !tokenAddress || !amountRaw || !directionRaw) continue; + + /* Validate direction */ + if (directionRaw !== 'IN' && directionRaw !== 'OUT') continue; + + /* Validate ISO timestamp format */ + if (isNaN(Date.parse(blockTimestamp))) continue; + + const record = { + block_timestamp: blockTimestamp, + token_address: tokenAddress, + amount: amountRaw, + direction: directionRaw, + }; + + /* Optional fields */ + const txHashRaw = row[colIdx['tx_hash']]; + if (txHashRaw && txHashRaw.trim()) { + record.tx_hash = txHashRaw.trim(); + } + + const blockNumRaw = row[colIdx['block_number']]; + if (blockNumRaw && blockNumRaw.trim()) { + const parsed = parseInt(blockNumRaw.trim(), 10); + if (!isNaN(parsed)) { + record.block_number = parsed; + } + } + + const fromAddrRaw = row[colIdx['from_address']]; + if (fromAddrRaw && fromAddrRaw.trim()) { + record.from_address = fromAddrRaw.trim(); + } + + const toAddrRaw = row[colIdx['to_address']]; + if (toAddrRaw && toAddrRaw.trim()) { + record.to_address = toAddrRaw.trim(); + } + + records.push(record); + } + + return records; +} + +/** + * Split CSV into rows, respecting quoted fields and embedded newlines. + * Returns array of arrays (rows → cells). + */ +function splitCsvRows(text) { + const rows = []; + let current = []; + let field = ''; + let inQuotes = false; + let i = 0; + + while (i < text.length) { + const ch = text[i]; + + if (inQuotes) { + if (ch === '"') { + if (i + 1 < text.length && text[i + 1] === '"') { + field += '"'; + i += 2; + } else { + inQuotes = false; + i++; + } + } else { + field += ch; + i++; + } + } else { + if (ch === '"') { + inQuotes = true; + i++; + } else if (ch === ',') { + current.push(field); + field = ''; + i++; + } else if (ch === '\n' || ch === '\r') { + if (ch === '\r' && i + 1 < text.length && text[i + 1] === '\n') { + i++; + } + current.push(field); + field = ''; + if (current.length > 0 && !(current.length === 1 && current[0] === '')) { + rows.push(current); + } + current = []; + i++; + } else { + field += ch; + i++; + } + } + } + + /* Last field / row */ + if (field !== '' || current.length > 0) { + current.push(field); + if (current.length > 0 && !(current.length === 1 && current[0] === '')) { + rows.push(current); + } + } + + return rows; +}