# Manual Portfolio Composition Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add dashboard-native portfolio editing: an EDIT button toggles inline edit mode with an add-position form (live ticker validation + avg-cost or bought-on-date toggle) and per-row delete; brand-new users see the same form in the empty state instead of being sent to /settings.
**Architecture:** Two new paid-only endpoints (`/api/ticker/validate`, `/api/ticker/historical`) thinly wrap the existing `fetch_yahoo` to give the JS the data it needs. A new `portfolio_edit.js` owns edit-mode toggle + form behaviour and writes positions via the existing `window.CassandraPortfolio.savePie` API. `portfolio.js` is touched only to (a) emit × buttons hidden by CSS and (b) replace the empty-state CTA. No server-side portfolio persistence; localStorage stays authoritative.
**Tech Stack:** FastAPI · `httpx.AsyncClient` · vanilla JS · Jinja2 templates · existing `app/services/market.fetch_yahoo` · existing `app/services/ticker_universe.upsert_tickers`
**Spec:** `docs/superpowers/specs/2026-05-27-manual-portfolio-composition-design.md`
---
## File Structure
**Create:**
- `app/routers/ticker_validate.py` — both new endpoints (validate + historical) + the `fetch_yahoo_historical` helper.
- `tests/test_ticker_validate.py` — backend tests.
- `app/static/js/portfolio_edit.js` — edit-mode + add-position form behaviour.
**Modify:**
- `app/main.py` — register the new router (one new line).
- `app/templates/dashboard.html` — EDIT button in panel header; hidden add-position form; new `
```
Replace with:
```html
Portfolioheld locally · prices via /api/universe
loading…
```
- [ ] **Step 2: Commit**
```bash
git add app/templates/dashboard.html
git commit -m "dashboard: scaffold portfolio edit-mode markup"
```
## Context for this task
- No tests for this — pure markup. JS will hide/show via class toggle.
- The pencil SVG path is the standard "edit" icon (Feather-style).
- All form fields are present in the DOM at all times; `portfolio_edit.js` shows/hides via CSS class on `#portfolio-panel`.
---
### Task 6: portfolio_edit.js — edit-mode toggle
**Files:**
- Create: `app/static/js/portfolio_edit.js`
- [ ] **Step 1: Create the scaffold with edit-mode toggle**
Create `app/static/js/portfolio_edit.js`:
```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);
})();
```
- [ ] **Step 2: Smoke-test in a browser**
Restart the app (manual prod step — request user approval), navigate to the dashboard, click EDIT. Expected: form appears, focus lands in the ticker input; Done button appears; Edit button hides. Click Done: reverse.
(No automated test; vanilla JS, no JS test framework in this repo.)
- [ ] **Step 3: Commit**
```bash
git add app/static/js/portfolio_edit.js
git commit -m "portfolio-edit: edit-mode toggle scaffold"
```
---
### Task 7: portfolio_edit.js — ticker validation on blur
**Files:**
- Modify: `app/static/js/portfolio_edit.js` (append to the existing IIFE before the closing `})();`)
- [ ] **Step 1: Add validation logic**
Inside the IIFE in `app/static/js/portfolio_edit.js`, before the final `})();`, append:
```javascript
// ---- 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);
```
- [ ] **Step 2: Smoke-test in browser**
Restart app. EDIT → type `AAPL`, tab out → expect green check with price + currency. Type bogus → red error. Add button stays disabled.
- [ ] **Step 3: Commit**
```bash
git add app/static/js/portfolio_edit.js
git commit -m "portfolio-edit: ticker validate on blur + duplicate warning"
```
---
### Task 8: portfolio_edit.js — Add button + localStorage merge
**Files:**
- Modify: `app/static/js/portfolio_edit.js` (append inside the IIFE)
- [ ] **Step 1: Add the submit handler**
Append (inside the same IIFE):
```javascript
// ---- 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();
}
});
```
- [ ] **Step 2: Smoke-test in browser**
EDIT → enter AAPL + qty 100 + cost 150.25 → Add. Row should appear in the table. Form clears, ticker input refocused. Refresh page; row persists (localStorage).
- [ ] **Step 3: Commit**
```bash
git add app/static/js/portfolio_edit.js
git commit -m "portfolio-edit: add button writes position to localStorage"
```
---
### Task 9: portfolio_edit.js — Bought-on-date mode + historical lookup
**Files:**
- Modify: `app/static/js/portfolio_edit.js` (append inside the IIFE)
- [ ] **Step 1: Add cost-mode toggle + historical lookup**
Append:
```javascript
// ---- Cost mode toggle + historical lookup --------------------------
const dateField = document.getElementById('pf-add-date-field');
const dateInput = document.getElementById('pf-add-date');
const dateStatus = document.getElementById('pf-add-date-status');
const costInput = document.getElementById('pf-add-cost');
function onModeChange() {
const mode = document.querySelector(
'input[name="pf-cost-mode"]:checked'
).value;
if (mode === 'date') {
dateField.hidden = false;
costInput.readOnly = true;
costInput.placeholder = 'auto-filled from date';
} else {
dateField.hidden = true;
costInput.readOnly = false;
costInput.placeholder = '150.25';
setStatus(dateStatus, '', '');
}
}
document.querySelectorAll('input[name="pf-cost-mode"]').forEach(r =>
r.addEventListener('change', onModeChange)
);
async function fetchHistorical() {
if (!validated) {
setStatus(dateStatus, 'enter a valid ticker first', 'err');
return;
}
const d = dateInput.value;
if (!d) {
setStatus(dateStatus, '', '');
costInput.value = '';
updateSubmitState();
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');
costInput.value = '';
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');
} else {
setStatus(dateStatus, '✗ ' + (j.error || 'no data'), 'err');
costInput.value = '';
}
} catch (e) {
setStatus(dateStatus, '✗ couldn’t fetch — try again', 'err');
costInput.value = '';
}
updateSubmitState();
}
dateInput.addEventListener('blur', fetchHistorical);
```
- [ ] **Step 2: Smoke-test in browser**
EDIT → AAPL validated → switch radio to "Bought on date" → cost field becomes read-only → pick a Friday → cost field auto-fills. Pick a Saturday → cost fills with previous Friday's close, "✓ from YYYY-MM-DD" tag visible. Pick today's date → fills with today's close. Pick a future date → red "date cannot be in the future".
- [ ] **Step 3: Commit**
```bash
git add app/static/js/portfolio_edit.js
git commit -m "portfolio-edit: bought-on-date mode + historical lookup"
```
---
### Task 10: portfolio.js — render × buttons + empty-state form
**Files:**
- Modify: `app/static/js/portfolio.js` (two changes: `renderEmpty` and the row-render code)
- [ ] **Step 1: Identify the row-render call site**
```bash
grep -n "
` rows for each position. (The skeleton is likely a `rows.map(...)` or string-concat. Locate it; this is where we add a per-row × cell.)
- [ ] **Step 2: Modify each row to include an × delete cell**
Inside the row-render loop, append a new `
` to each row's HTML:
```javascript
'
' +
'' +
'
'
```
(Use whichever loop variable holds the index. If positions are iterated with `.map((p, i) => ...)`, `i` is the index. The × cell goes at the END of each `
`.)
Also add a matching empty `
` to the table header row so column counts line up.
- [ ] **Step 3: Replace the empty-state render**
Find `function renderEmpty(mount)` (~line 180). Replace its body:
```javascript
function renderEmpty(mount) {
var notice = window._cassandraPortfolioBackupExpired
? '
Your encrypted cloud backup expired. ' +
'Please re-upload your portfolio to refresh it.' +
'
'
: '';
var panel = document.getElementById('portfolio-panel');
if (panel) panel.classList.add('pf-empty');
mount.innerHTML =
'