' +
@@ -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;
+}