diff --git a/app/static/js/settings-import.js b/app/static/js/settings-import.js new file mode 100644 index 0000000..6ec4692 --- /dev/null +++ b/app/static/js/settings-import.js @@ -0,0 +1,245 @@ +(function () { + 'use strict'; + + // Server-side hint: did the user have paid privileges when the page + // rendered? Used to decide whether to offer the 'Import & sync' button. + // We still call CassandraSync.getStatus() at click time as the source + // of truth, but this lets us skip rendering a button we know is dead. + // Value is passed via data-paid attribute on #drop-zone. + + function ready(fn) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', fn); + } else { fn(); } + } + + ready(function () { + var P = window.CassandraPortfolio; + if (!P) return; + var esc = P.esc, fmt = P.fmt, signed = P.signed, cls = P.cls; + + var dropZone = document.getElementById('drop-zone'); + var fileInput = document.getElementById('file-input'); + var browseLink = document.getElementById('browse-link'); + var filenameEl = document.getElementById('dz-filename'); + var previewEl = document.getElementById('import-preview'); + var resultEl = document.getElementById('import-result'); + if (!dropZone) return; + + var IS_PAID = dropZone.dataset.paid === 'true'; + + var currentPie = null; // most recently parsed pie, awaiting commit + + function showError(msg) { + previewEl.hidden = true; + resultEl.className = 'result result--err'; + resultEl.innerHTML = + '
✕ Import failed
' + + '
' + esc(msg) + '
'; + resultEl.hidden = false; + } + + function showSuccess(headline, sub) { + previewEl.hidden = true; + resultEl.className = 'result result--ok'; + resultEl.innerHTML = + '
' + esc(headline) + '
' + + (sub ? '
' + sub + '
' : '') + + '
' + + 'Open dashboard →' + + '
'; + resultEl.hidden = false; + } + + function renderPreview(pie) { + currentPie = pie; + resultEl.hidden = true; + + var t = pie.totals || {}; + var rows = (pie.positions || []).map(function (p) { + var invested = (p.avg_cost != null && p.qty != null) ? p.avg_cost * p.qty : null; + return '' + + '' + esc(p.yahoo_ticker || p.t212_slice || '') + '' + + '' + esc(p.name || '') + '' + + '' + fmt(p.qty, { maximumFractionDigits: 6 }) + '' + + '' + fmt(p.avg_cost) + '' + + '' + fmt(invested) + '' + + ''; + }).join(''); + + var warnings = (pie.warnings || []).map(function (w) { + return '
' + esc(w) + '
'; + }).join(''); + + var syncBtn = IS_PAID + ? ('
' + + '' + + '
' + + 'Also stores an encrypted copy on the server, ' + + 'restorable on any device with your PIN. Only you can decrypt ' + + 'it — losing the PIN means losing the backup.' + + '
' + + '
') + : ('
' + + '' + + '
' + + 'Encrypted cloud backup is available on the paid tier.' + + '
' + + '
'); + + previewEl.innerHTML = + '
' + + '
' + + '▸ Preview: ' + esc(pie.pie_name || 'pie') + '' + + '
' + + '
' + + '
Positions
' + (pie.positions || []).length + '
' + + '
Invested
' + fmt(t.invested) + '
' + + '
Value
' + fmt(t.value) + '
' + + '
Result
' + signed(t.result) + '
' + + '
' + + warnings + + (rows + ? '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rows + '' + + '
TickerNameQtyAvgInvested
' + + '
' + : '' + ) + + '
' + + '
' + + '' + + '
' + + 'Saved to this browser only. No server-side copy of your holdings.' + + '
' + + '
' + + syncBtn + + '
' + + '' + + '
' + + '
' + + '
'; + + previewEl.hidden = false; + + document.getElementById('commit-local').addEventListener('click', commitLocal); + document.getElementById('commit-cancel').addEventListener('click', resetUploader); + var syncEl = document.getElementById('commit-sync'); + if (syncEl) syncEl.addEventListener('click', commitSync); + } + + function commitLocal() { + if (!currentPie) return; + P.savePie(currentPie); + showSuccess('▸ Imported to this browser.', + 'Pie kept locally; no server-side copy.'); + currentPie = null; + } + + async function commitSync() { + if (!currentPie) return; + // Save locally first so the cloud-sync flow uses the freshly-imported + // pie (the enable-PIN modal in this same page reads from localStorage). + P.savePie(currentPie); + var S = window.CassandraSync; + if (!S) { showError('Cloud sync module not loaded.'); return; } + + var status; + try { status = await S.getStatus(); } + catch (e) { showError('Could not check sync status: ' + (e.message || e)); return; } + + if (!status.paid) { + showError('Cloud sync requires the paid tier.'); + return; + } + + if (status.exists) { + // Already enabled — try a direct push using the cached session + // key. If no key is cached (fresh browser session), this throws, + // and we fall back to the enable-PIN modal so the user can + // re-enter their PIN. + try { + await S.pushSync(currentPie, null); + showSuccess('▸ Imported and synced.', + 'Encrypted copy updated on the server.'); + currentPie = null; + if (window.cassandraRefreshSyncStatus) window.cassandraRefreshSyncStatus(); + return; + } catch (e) { + // Fall through to modal so the user can re-auth with their PIN. + console.warn('direct push failed, falling back to PIN modal', e); + } + } + + // !status.exists OR cached-key push failed → use the modal. + if (window.cassandraOpenSyncModal) { + window.cassandraOpenSyncModal({ + onSuccess: function () { + showSuccess('▸ Imported and synced.', + 'Cloud sync is now enabled and the pie is stored encrypted.'); + currentPie = null; + }, + }); + } else { + showError('Cloud sync UI unavailable on this page. ' + + 'Use the Cloud sync section below to enable.'); + } + } + + function resetUploader() { + currentPie = null; + previewEl.hidden = true; + previewEl.innerHTML = ''; + resultEl.hidden = true; + filenameEl.textContent = ''; + fileInput.value = ''; + } + + async function parseFile(file) { + filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB) — parsing…'; + previewEl.hidden = true; + resultEl.hidden = true; + try { + var pie = await P.parseCsv(file); + renderPreview(pie); + filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)'; + } catch (e) { + filenameEl.textContent = file.name + ' (failed)'; + showError(e.message || 'Unknown error'); + } + } + + browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); }); + fileInput.addEventListener('change', function () { + if (fileInput.files[0]) parseFile(fileInput.files[0]); + }); + + ['dragenter', 'dragover'].forEach(function (ev) { + dropZone.addEventListener(ev, function (e) { + e.preventDefault(); e.stopPropagation(); + dropZone.classList.add('dz--over'); + }); + }); + ['dragleave', 'drop'].forEach(function (ev) { + dropZone.addEventListener(ev, function (e) { + e.preventDefault(); e.stopPropagation(); + dropZone.classList.remove('dz--over'); + }); + }); + dropZone.addEventListener('drop', function (e) { + var f = e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) parseFile(f); + }); + dropZone.addEventListener('click', function (e) { + if (e.target.tagName !== 'A') fileInput.click(); + }); + }); +})(); diff --git a/app/static/js/settings-sync.js b/app/static/js/settings-sync.js new file mode 100644 index 0000000..836ce7f --- /dev/null +++ b/app/static/js/settings-sync.js @@ -0,0 +1,154 @@ +(function () { + 'use strict'; + + function $(id) { return document.getElementById(id); } + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ + '&':'&','<':'<','>':'>','"':'"',"'":''' + }[c])); + } + + document.addEventListener('DOMContentLoaded', function () { + if (!window.CassandraSync) return; + + const statusEl = $('sync-status'); + const actionsEl = $('sync-actions'); + const feedbackEl = $('sync-feedback'); + const modal = $('sync-modal'); + const pin1 = $('sync-pin1'); + const pin2 = $('sync-pin2'); + const ack = $('sync-ack'); + const errEl = $('sync-modal-err'); + + function setFeedback(msg, ok) { + feedbackEl.style.color = ok ? 'var(--positive)' : ''; + feedbackEl.textContent = msg || ''; + } + // External callers (the Import section above) can pass a callback + // that fires after a successful enable-and-push. + let pendingOnSuccess = null; + function openModal(opts) { + pendingOnSuccess = (opts && opts.onSuccess) || null; + modal.style.display = 'flex'; + // Focus PIN field after the layout flush so the caret lands. + setTimeout(() => pin1.focus(), 0); + } + function closeModal() { + modal.style.display = 'none'; + pin1.value = ''; pin2.value = ''; + ack.checked = false; errEl.hidden = true; + pendingOnSuccess = null; + } + + $('sync-modal-cancel').addEventListener('click', closeModal); + // Backdrop click + Esc key dismiss the modal. + modal.addEventListener('click', function (e) { + if (e.target === modal) closeModal(); + }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && modal.style.display !== 'none') closeModal(); + }); + + $('sync-modal-form').addEventListener('submit', async function (e) { + e.preventDefault(); + errEl.hidden = true; + if (pin1.value !== pin2.value) { + errEl.textContent = 'PINs do not match.'; + errEl.hidden = false; return; + } + if (pin1.value.length < 4) { + errEl.textContent = 'PIN must be at least 4 characters.'; + errEl.hidden = false; return; + } + const pie = JSON.parse(localStorage.getItem('cassandra.pie') || 'null'); + if (!pie) { + errEl.textContent = + 'No portfolio in this browser yet. Import a CSV first, then enable sync.'; + errEl.hidden = false; return; + } + try { + await window.CassandraSync.pushSync(pie, pin1.value); + const cb = pendingOnSuccess; + closeModal(); // clears pendingOnSuccess + await refresh(); + setFeedback('Cloud sync enabled. Your encrypted portfolio is stored.', true); + if (typeof cb === 'function') { + try { cb(); } catch (cbErr) { console.warn('sync onSuccess threw', cbErr); } + } + } catch (e2) { + errEl.textContent = e2.message || 'Failed to enable sync.'; + errEl.hidden = false; + } + }); + + async function refresh() { + let status; + try { status = await window.CassandraSync.getStatus(); } + catch (e) { + statusEl.querySelector('.settings-row__value').innerHTML = + '' + esc(e.message || 'status check failed') + ''; + return; + } + const valueEl = statusEl.querySelector('.settings-row__value'); + actionsEl.innerHTML = ''; + if (status.exists && status.orphaned) { + // The stored blob can no longer be decrypted (server key rotated + // since it was written). The data is permanently unrecoverable, + // so silently clean up the dead row and re-render in the + // standard "off" state — leaving a soft one-liner so the user + // knows why they need to re-import. + try { await window.CassandraSync.disableSync(); } + catch (e) { console.warn('auto-clear stale sync failed', e); } + setFeedback('Your previous cloud backup couldn’t be restored. Re-import your portfolio to enable cloud sync again.', true); + await refresh(); + return; + } else if (status.exists) { + const when = status.updated_at + ? new Date(status.updated_at).toISOString().replace('T', ' ').slice(0, 16) + ' UTC' + : '—'; + valueEl.innerHTML = + 'On ' + + 'last synced ' + esc(when) + ''; + + const disable = document.createElement('button'); + disable.type = 'button'; + disable.className = 'pf-secondary'; + disable.textContent = 'Disable sync'; + disable.addEventListener('click', async function () { + if (!confirm('Remove your encrypted portfolio from the server? Your local copy is untouched.')) return; + try { + await window.CassandraSync.disableSync(); + await refresh(); + setFeedback('Cloud sync disabled. Server copy removed.', true); + } catch (e) { setFeedback(e.message || 'Disable failed.', false); } + }); + actionsEl.appendChild(disable); + } else { + valueEl.innerHTML = 'Off'; + // Only offer 'Enable' when there's actually a pie to encrypt; + // otherwise the user would hit a dead-end at the modal. + const hasPie = !!localStorage.getItem('cassandra.pie'); + if (!hasPie) { + const hint = document.createElement('span'); + hint.className = 'settings-row__hint'; + hint.innerHTML = + 'Nothing to sync yet — ' + + 'import a portfolio first, then come back to enable cloud sync.'; + actionsEl.appendChild(hint); + return; + } + const enable = document.createElement('button'); + enable.type = 'button'; + enable.textContent = 'Enable cloud sync'; + enable.addEventListener('click', openModal); + actionsEl.appendChild(enable); + } + } + + // Hooks for the Import section to drive this modal + status row. + window.cassandraOpenSyncModal = openModal; + window.cassandraRefreshSyncStatus = refresh; + + refresh(); + }); +})(); diff --git a/app/templates/settings.html b/app/templates/settings.html index 98dccd9..95edef8 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -106,7 +106,7 @@ Investing → Your Pie → ··· → Export.

-
+
Drop your broker's portfolio CSV here
@@ -332,161 +332,8 @@
- - + + {% endif %} - + {% endif %} {% endblock %}