A single-quoted string literal "couldn't validate" was breaking the parse because the apostrophe wasn't escaped. The page logged a syntax error and none of the edit-mode JS ran. Backslash-escape it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
256 lines
8.6 KiB
JavaScript
256 lines
8.6 KiB
JavaScript
/* Dashboard-native portfolio editing.
|
|
*
|
|
* Owns: the EDIT button toggle, the add-position form behaviour
|
|
* (ticker validation on blur, qty/cost inputs, date-mode historical
|
|
* lookup, Add click), and per-row delete via event delegation.
|
|
*
|
|
* Reads/writes the portfolio via window.CassandraPortfolio.loadPie /
|
|
* savePie / mountAndRender — the same surface portfolio.js exposes
|
|
* for the CSV-import preview.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const panel = document.getElementById('portfolio-panel');
|
|
const editBtn = document.getElementById('pf-edit-btn');
|
|
const doneBtn = document.getElementById('pf-done-btn');
|
|
const form = document.getElementById('pf-add-form');
|
|
if (!panel || !editBtn || !doneBtn || !form) return;
|
|
|
|
function enterEditMode() {
|
|
panel.classList.add('pf-editing');
|
|
form.hidden = false;
|
|
editBtn.hidden = true;
|
|
doneBtn.hidden = false;
|
|
editBtn.setAttribute('aria-pressed', 'true');
|
|
document.getElementById('pf-add-ticker').focus();
|
|
}
|
|
|
|
function exitEditMode() {
|
|
panel.classList.remove('pf-editing');
|
|
// Form stays visible when the pie is empty (empty-state UX handled
|
|
// by portfolio.js setting the pf-empty class on the panel).
|
|
if (!panel.classList.contains('pf-empty')) {
|
|
form.hidden = true;
|
|
}
|
|
editBtn.hidden = false;
|
|
doneBtn.hidden = true;
|
|
editBtn.setAttribute('aria-pressed', 'false');
|
|
}
|
|
|
|
editBtn.addEventListener('click', enterEditMode);
|
|
doneBtn.addEventListener('click', exitEditMode);
|
|
|
|
// ---- Ticker validation on blur -------------------------------------
|
|
|
|
const tickerInput = document.getElementById('pf-add-ticker');
|
|
const tickerStatus = document.getElementById('pf-add-ticker-status');
|
|
const costCurrencyEl = document.getElementById('pf-add-cost-currency');
|
|
const submitBtn = document.getElementById('pf-add-submit');
|
|
const warningEl = document.getElementById('pf-add-warning');
|
|
|
|
let validated = null; // {symbol, price, currency, as_of} or null
|
|
|
|
function setStatus(el, text, kind) {
|
|
el.textContent = text;
|
|
el.className = 'pf-add-status' + (kind ? ' pf-add-status--' + kind : '');
|
|
}
|
|
|
|
function updateSubmitState() {
|
|
const qty = parseFloat(document.getElementById('pf-add-qty').value);
|
|
const cost = parseFloat(document.getElementById('pf-add-cost').value);
|
|
submitBtn.disabled = !(
|
|
validated && qty > 0 && cost > 0 && isFinite(qty) && isFinite(cost)
|
|
);
|
|
}
|
|
|
|
function clearDuplicateWarning() {
|
|
warningEl.hidden = true;
|
|
warningEl.textContent = '';
|
|
}
|
|
|
|
function showDuplicateWarning(existing) {
|
|
warningEl.hidden = false;
|
|
warningEl.textContent =
|
|
`Already in your portfolio (${existing.qty} shares @ ` +
|
|
`${existing.avg_cost.toFixed(2)}). Adding will create a duplicate row.`;
|
|
}
|
|
|
|
async function validateTicker() {
|
|
const raw = tickerInput.value.trim().toUpperCase();
|
|
if (!raw) {
|
|
validated = null;
|
|
setStatus(tickerStatus, '', '');
|
|
costCurrencyEl.textContent = '';
|
|
clearDuplicateWarning();
|
|
updateSubmitState();
|
|
return;
|
|
}
|
|
setStatus(tickerStatus, 'checking…', 'pending');
|
|
try {
|
|
const r = await fetch('/api/ticker/validate?symbol=' + encodeURIComponent(raw));
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
validated = j;
|
|
setStatus(
|
|
tickerStatus,
|
|
'✓ ' + j.price.toFixed(2) + ' ' + (j.currency || ''),
|
|
'ok',
|
|
);
|
|
costCurrencyEl.textContent = j.currency || '';
|
|
// Duplicate detection.
|
|
const pie = window.CassandraPortfolio.loadPie();
|
|
const existing = pie && (pie.positions || []).find(
|
|
p => (p.yahoo_ticker || '').toUpperCase() === j.symbol
|
|
);
|
|
if (existing) showDuplicateWarning(existing);
|
|
else clearDuplicateWarning();
|
|
} else {
|
|
validated = null;
|
|
setStatus(tickerStatus, '✗ ' + (j.error || 'not recognised'), 'err');
|
|
costCurrencyEl.textContent = '';
|
|
clearDuplicateWarning();
|
|
}
|
|
} catch (e) {
|
|
validated = null;
|
|
setStatus(tickerStatus, '✗ couldn\'t validate — try again', 'err');
|
|
costCurrencyEl.textContent = '';
|
|
clearDuplicateWarning();
|
|
}
|
|
updateSubmitState();
|
|
}
|
|
|
|
tickerInput.addEventListener('blur', validateTicker);
|
|
document.getElementById('pf-add-qty').addEventListener('input', updateSubmitState);
|
|
document.getElementById('pf-add-cost').addEventListener('input', updateSubmitState);
|
|
|
|
// ---- Add button → localStorage merge -------------------------------
|
|
|
|
function resetForm() {
|
|
tickerInput.value = '';
|
|
document.getElementById('pf-add-qty').value = '';
|
|
document.getElementById('pf-add-cost').value = '';
|
|
document.getElementById('pf-add-date').value = '';
|
|
validated = null;
|
|
setStatus(tickerStatus, '', '');
|
|
costCurrencyEl.textContent = '';
|
|
clearDuplicateWarning();
|
|
updateSubmitState();
|
|
tickerInput.focus();
|
|
}
|
|
|
|
function addPosition() {
|
|
if (submitBtn.disabled) return;
|
|
const qty = parseFloat(document.getElementById('pf-add-qty').value);
|
|
const cost = parseFloat(document.getElementById('pf-add-cost').value);
|
|
const sym = validated.symbol;
|
|
|
|
const pie = window.CassandraPortfolio.loadPie() || {
|
|
pie_name: null,
|
|
base_currency: 'GBP',
|
|
positions: [],
|
|
totals: {invested: 0, value: 0, result: 0},
|
|
warnings: [],
|
|
};
|
|
pie.positions = pie.positions || [];
|
|
pie.positions.push({
|
|
yahoo_ticker: sym,
|
|
t212_slice: sym, // shared shape with CSV path
|
|
name: validated.name || sym,
|
|
qty: qty,
|
|
avg_cost: cost,
|
|
currency: validated.currency || 'USD',
|
|
});
|
|
window.CassandraPortfolio.savePie(pie);
|
|
window.CassandraPortfolio.mountAndRender();
|
|
resetForm();
|
|
}
|
|
|
|
submitBtn.addEventListener('click', addPosition);
|
|
|
|
// Submit on Enter from any input within the form.
|
|
form.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && !submitBtn.disabled) {
|
|
e.preventDefault();
|
|
addPosition();
|
|
}
|
|
});
|
|
|
|
// ---- Calendar-icon → historical lookup -----------------------------
|
|
|
|
const dateBtn = document.getElementById('pf-add-date-btn');
|
|
const dateInput = document.getElementById('pf-add-date');
|
|
const dateStatus = document.getElementById('pf-add-date-status');
|
|
const costInput = document.getElementById('pf-add-cost');
|
|
|
|
dateBtn.addEventListener('click', function () {
|
|
if (!validated) {
|
|
setStatus(dateStatus, 'enter a valid ticker first', 'err');
|
|
return;
|
|
}
|
|
dateInput.hidden = !dateInput.hidden;
|
|
if (!dateInput.hidden) {
|
|
dateInput.focus();
|
|
if (typeof dateInput.showPicker === 'function') dateInput.showPicker();
|
|
} else {
|
|
setStatus(dateStatus, '', '');
|
|
}
|
|
});
|
|
|
|
async function fetchHistorical() {
|
|
if (!validated) {
|
|
setStatus(dateStatus, 'enter a valid ticker first', 'err');
|
|
return;
|
|
}
|
|
const d = dateInput.value;
|
|
if (!d) {
|
|
setStatus(dateStatus, '', '');
|
|
return;
|
|
}
|
|
setStatus(dateStatus, 'looking up…', 'pending');
|
|
try {
|
|
const url = '/api/ticker/historical?symbol=' +
|
|
encodeURIComponent(validated.symbol) +
|
|
'&date=' + encodeURIComponent(d);
|
|
const r = await fetch(url);
|
|
if (r.status === 400) {
|
|
const j = await r.json().catch(() => ({detail: 'invalid date'}));
|
|
setStatus(dateStatus, '✗ ' + (j.detail || 'invalid date'), 'err');
|
|
updateSubmitState();
|
|
return;
|
|
}
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
costInput.value = j.close.toFixed(2);
|
|
const tag = (j.actual_date && j.actual_date !== d)
|
|
? '✓ from ' + j.actual_date
|
|
: '✓';
|
|
setStatus(dateStatus, tag, 'ok');
|
|
// Hide the date picker after a successful fill — keeps the row clean.
|
|
dateInput.hidden = true;
|
|
} else {
|
|
setStatus(dateStatus, '✗ ' + (j.error || 'no data'), 'err');
|
|
}
|
|
} catch (e) {
|
|
setStatus(dateStatus, '✗ couldn\'t fetch — try again', 'err');
|
|
}
|
|
updateSubmitState();
|
|
}
|
|
|
|
dateInput.addEventListener('change', fetchHistorical);
|
|
|
|
// ---- Per-row delete (event delegation) -----------------------------
|
|
|
|
panel.addEventListener('click', function (e) {
|
|
const btn = e.target.closest('.pf-row-del');
|
|
if (!btn) return;
|
|
const idx = parseInt(btn.dataset.idx, 10);
|
|
if (!Number.isInteger(idx)) return;
|
|
const pie = window.CassandraPortfolio.loadPie();
|
|
if (!pie || !pie.positions || idx < 0 || idx >= pie.positions.length) return;
|
|
pie.positions.splice(idx, 1);
|
|
window.CassandraPortfolio.savePie(pie);
|
|
window.CassandraPortfolio.mountAndRender();
|
|
});
|
|
})();
|