fix: extend chart x-axes to current date for cumul, sats, and breakdown charts

This commit is contained in:
Dione
2026-06-13 06:41:57 +00:00
parent 32c20e67a2
commit 11c292eb90

View File

@ -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); } }
</style>
</head>
<body class="p-4 md:p-8 min-h-screen text-white">
@ -134,11 +156,15 @@ window.WalletManager = WalletManager;
<button onclick="closeSidebar()" class="text-gray-500 hover:text-white transition"></button>
</div>
<div id="sidebar-wallet-list" class="p-4 space-y-3"></div>
<div class="p-4 border-t border-[#1A1F2C]">
<div class="p-4 border-t border-[#1A1F2C] space-y-3">
<button onclick="openAddWalletModal()" class="w-full bg-[#FF7A00]/10 border border-[#FF7A00]/30 text-[#FF7A00] hover:bg-[#FF7A00]/20 px-4 py-3 rounded-lg font-bold text-sm transition flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Wallet
</button>
<button onclick="openImportCsvModal()" class="w-full bg-[#3b82f6]/10 border border-[#3b82f6]/30 text-[#58a6ff] hover:bg-[#3b82f6]/20 px-4 py-3 rounded-lg font-bold text-sm transition flex items-center justify-center gap-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import CSV
</button>
</div>
</div>
@ -180,6 +206,53 @@ window.WalletManager = WalletManager;
<!-- Color Picker Popup -->
<div id="color-picker-overlay" class="color-picker-overlay" onclick="closeColorPicker()"></div>
<!-- Import CSV Modal -->
<div id="import-csv-modal" class="modal-overlay" onclick="if(event.target===this)closeImportCsvModal()">
<div class="modal-box">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-white">Import Transaction History</h3>
<button onclick="closeImportCsvModal()" class="text-gray-500 hover:text-white text-xl leading-none transition"></button>
</div>
<form id="import-csv-form" onsubmit="return handleImportCsv(event)">
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Nickname <span class="text-red-400">*</span></label>
<input id="import-nickname" type="text" placeholder="Whale Wallet A" class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-[#58a6ff]/50 transition" required>
</div>
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">Wallet Address</label>
<input id="import-address" type="text" placeholder="0x000..." class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-white text-sm placeholder-gray-600 transition font-mono">
</div>
<div class="mb-4">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-2">Color</label>
<div id="import-color-picker" class="flex flex-wrap gap-2"></div>
</div>
<div class="mb-6">
<label class="block text-xs font-bold text-gray-500 uppercase tracking-wider mb-1">CSV File <span class="text-red-400">*</span></label>
<div id="import-file-display" class="bg-[#05070B] border border-[#1A1F2C] rounded-lg px-3 py-2 text-sm text-gray-500 cursor-pointer hover:border-[#58a6ff]/50 transition flex items-center gap-2" onclick="document.getElementById('csv-import-input').click()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span id="import-filename">Click to select a .csv file</span>
</div>
</div>
<div id="import-csv-error" class="hidden mb-3 text-red-400 text-xs font-medium"></div>
<div id="import-csv-progress" class="hidden mb-3">
<div class="text-[10px] font-bold text-gray-500 uppercase mb-1">Uploading...</div>
<div class="w-full bg-[#05070B] border border-[#1A1F2C] rounded-full h-2 overflow-hidden">
<div class="import-progress-bar h-full bg-[#58a6ff] rounded-full animate-pulse"></div>
</div>
</div>
<button type="submit" id="import-csv-submit" class="w-full bg-[#3b82f6] hover:bg-[#3b82f6]/90 text-white font-bold px-4 py-3 rounded-lg transition">
<span id="import-submit-text">Import CSV</span>
</button>
</form>
</div>
</div>
<!-- Toast Notification -->
<div id="toast-container" class="fixed bottom-6 right-6 z-[90] flex flex-col gap-2"></div>
<!-- Hidden CSV File Input -->
<input id="csv-import-input" type="file" accept=".csv,text/csv" class="hidden" onchange="handleCsvFileSelect(event)">
<!-- Main Dashboard -->
<div class="max-w-7xl mx-auto">
<!-- Top Nav Bar -->
@ -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
? '<button onclick="handleVerifyWallet(\'' + w.address + '\',\'' + w.chain + '\',\'' + nick + '\')" class="text-[10px] bg-[#FF7A00]/20 hover:bg-[#FF7A00]/40 text-[#FF7A00] font-bold px-2 py-0.5 rounded transition" title="Verify ownership via MetaMask">Verify</button>'
: '';
html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' +
html += '<div class="flex items-center justify-between bg-[#05070B] border border-[#1A1F2C]/40 rounded-lg px-3 py-2.5">' +
'<div class="flex items-center gap-2.5 min-w-0 flex-1">' +
'<div onclick="openColorPicker(event, \'' + w.address + '\')" class="wallet-color-dot" style="--wallet-color:' + color + ';background:' + color + ';" title="Click to change color"></div>' +
'<div class="min-w-0 flex-1">' +
@ -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 =
'<div class="toast-icon">' + icon + '</div>' +
'<div class="toast-body">' +
'<div class="toast-title">' + title + '</div>' +
'<div class="toast-message">' + message + '</div>' +
'<div class="toast-progress"><div class="toast-progress-bar" style="width:100%;background:' + barColor + ';"></div></div>' +
'</div>';
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;
}
</script>
</body>
</html>