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 + '
' : '') +
+ '';
+ 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
+ ? ('' +
+ '
Import & sync to cloud ' +
+ '
' +
+ '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.' +
+ '
' +
+ '
')
+ : ('' +
+ '
Import & sync to cloud ' +
+ '
' +
+ '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
+ ? '
' +
+ '
' +
+ '' +
+ 'Ticker Name ' +
+ 'Qty ' +
+ 'Avg ' +
+ 'Invested ' +
+ ' ' +
+ '' + rows + ' ' +
+ '
' +
+ '
'
+ : ''
+ ) +
+ '
' +
+ '
' +
+ '
Import to this browser ' +
+ '
' +
+ 'Saved to this browser only. No server-side copy of your holdings.' +
+ '
' +
+ '
' +
+ syncBtn +
+ '
' +
+ '' +
+ 'Cancel ' +
+ '
' +
+ '
' +
+ '
';
+
+ 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 %}