Two related polishes:
- The add form was auto-shown by the empty-state path so brand-new
users would see something to act on. That conflicts with the user's
preference for "Edit always toggles the form, no other path." The
empty state now shows guidance copy ("click edit to add one")
instead. exitEditMode always hides the form too.
- The submit "add" word-button is replaced by a square accent-bordered
+ glyph (26×26). Matches the visual weight of the calendar ghost
next to it but stays in the accent colour so it reads as primary.
Adds a tiny active-state scale tick for tactile feedback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
255 lines
8.6 KiB
JavaScript
255 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');
|
|
// The form is edit-mode-only — always hide it on exit, including
|
|
// when the portfolio is empty. The empty state shows guidance text
|
|
// that nudges the user back to the Edit button.
|
|
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();
|
|
});
|
|
})();
|