# 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
Portfolio held 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 = '
' + notice + 'Welcome — start by adding a position above, or ' + 'import a CSV from your broker →' + '
'; // When empty, the add form should be visible by default — the // edit module toggles it via the pf-empty class. var form = document.getElementById('pf-add-form'); if (form) form.hidden = false; } ``` Also, in the function that runs after a successful render (where the panel is NOT empty), remove the `pf-empty` class: ```javascript // After successful render of populated positions: var panel = document.getElementById('portfolio-panel'); if (panel) panel.classList.remove('pf-empty'); ``` - [ ] **Step 4: Add the × click handler in portfolio_edit.js** In `app/static/js/portfolio_edit.js`, append (inside the same IIFE): ```javascript // ---- 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(); }); ``` - [ ] **Step 5: Smoke-test** Restart app. Visit dashboard with a populated pie → no × visible. Click EDIT → × appears on each row. Click an × → that row vanishes. Refresh page → row stays gone. Visit dashboard with a CLEARED pie (open devtools → `localStorage.removeItem('cassandra.pie')`, refresh) → empty state shows the inline form + "Or import a CSV" link. - [ ] **Step 6: Commit** ```bash git add app/static/js/portfolio.js app/static/js/portfolio_edit.js git commit -m "portfolio: render hidden × per row; empty state shows add form" ``` --- ### Task 11: CSS for edit mode + form **Files:** - Modify: `app/static/css/cassandra.css` (append a new block) - [ ] **Step 1: Append the edit-mode + form styles** Append to `app/static/css/cassandra.css`: ```css /* ---------- Dashboard portfolio edit mode ----------------------------- */ /* The EDIT button — same shape as .settings-icon-btn but smaller. */ .pf-edit-btn, .pf-done-btn { display: inline-flex; align-items: center; gap: 4px; background: transparent; border: 1px solid var(--neu-dim, #444); color: var(--text, #ccc); padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: auto; } .pf-edit-btn:hover, .pf-done-btn:hover { background: var(--surface-2, #2a2a2a); border-color: var(--accent, #5af); } /* × button on each row — hidden by default, visible only in edit mode. */ .pf-row-del-cell { width: 24px; text-align: center; } .pf-row-del { display: none; background: transparent; border: none; color: var(--neu-dim, #888); cursor: pointer; font-size: 14px; padding: 0 4px; } #portfolio-panel.pf-editing .pf-row-del { display: inline; } #portfolio-panel.pf-editing .pf-row-del:hover { color: var(--err, #f55); } /* Add-position form. */ .pf-add-form { border: 1px solid var(--neu-dim, #333); border-radius: 6px; padding: 12px; margin-bottom: 12px; background: var(--surface-2, #1a1a1a); } .pf-add-row { display: flex; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; align-items: flex-end; } .pf-add-field { display: flex; flex-direction: column; flex: 1 1 140px; font-size: 12px; } .pf-add-label { color: var(--neu-dim, #888); margin-bottom: 2px; } .pf-add-field input[type="text"], .pf-add-field input[type="number"], .pf-add-field input[type="date"] { background: var(--surface, #111); border: 1px solid var(--neu-dim, #444); color: var(--text, #ccc); padding: 4px 6px; border-radius: 3px; font-family: inherit; font-size: 13px; } .pf-add-cost-mode { gap: 16px; } .pf-add-radio { display: inline-flex; gap: 4px; align-items: center; font-size: 12px; cursor: pointer; } .pf-add-currency { color: var(--neu-dim, #888); font-size: 11px; margin-top: 2px; } .pf-add-submit { background: var(--accent, #5af); color: var(--bg, #000); border: none; padding: 6px 14px; border-radius: 3px; cursor: pointer; font-weight: 600; align-self: flex-end; } .pf-add-submit:disabled { background: var(--neu-dim, #444); cursor: not-allowed; } .pf-add-status { font-size: 11px; margin-top: 2px; min-height: 14px; } .pf-add-status--pending { color: var(--neu-dim, #888); } .pf-add-status--ok { color: var(--ok, #6c6); } .pf-add-status--err { color: var(--err, #f55); } .pf-add-warning { color: var(--warn, #fb3); font-size: 11px; margin-top: 8px; } ``` - [ ] **Step 2: Smoke-test all visual states** Browser smoke: - Light/dark theme rendering of the form. - Disabled Add button looks visibly disabled. - × buttons appear only in edit mode. - Status text colours change correctly across pending/ok/err. - [ ] **Step 3: Commit** ```bash git add app/static/css/cassandra.css git commit -m "css: portfolio edit-mode + add-position form styles" ``` --- ### Task 12: Final regression + manual deploy verification **Files:** - (no code changes — verification only) - [ ] **Step 1: Run the full test suite** ```bash docker compose -f docker-compose.test.yml run --rm test pytest tests/ 2>&1 | tail -5 ``` Expected: every test passes including the new `tests/test_ticker_validate.py`. Should land in the high-260s now. - [ ] **Step 2: Deploy (requires explicit user approval)** ```bash docker compose restart app docker compose logs app --tail 30 | grep -E "(Uvicorn|startup complete|ERROR|Traceback)" ``` Expected: clean startup; no tracebacks. NOTE: this is a prod restart on this host — do not run without explicit user authorisation. - [ ] **Step 3: Manual smoke — populated portfolio** In a paid-tier browser session: 1. Click EDIT → form appears, × buttons appear, focus lands in ticker input. 2. Type a known symbol → green check + price + currency. 3. Enter qty + cost → Add. New row appears. Form clears. 4. Repeat with date mode: pick a weekday → cost auto-fills. Pick a Saturday → cost fills with Friday's close, "from YYYY-MM-DD" tag shows. 5. Click × on any row → row vanishes; refresh confirms persistence. 6. Click Done → form hides, × hidden, dashboard returns to normal display. - [ ] **Step 4: Manual smoke — empty state** In devtools: `localStorage.removeItem('cassandra.pie')`. Refresh. Expected: the add-position form is visible at the top of the portfolio area; below it the message "Welcome — start by adding a position above, or import a CSV from your broker →". Add one position; the empty state disappears. - [ ] **Step 5: Manual smoke — paid gating** Open an incognito session as a free-tier user. Direct `GET /api/ticker/validate?symbol=AAPL` (devtools network tab or curl). Expect 402. - [ ] **Step 6: Manual smoke — error states** - Bad symbol `XYZNOTREAL` → red "Not recognised", Add stays disabled. - Pick future date → red "date cannot be in the future" inline. - Add a duplicate ticker → inline warning under the ticker field, but the Add button remains enabled (with valid qty+cost). --- ## Self-Review **Spec coverage walkthrough:** - **Dashboard placement (not Settings)** → Task 5 puts everything inside `#portfolio-panel`. - **EDIT button toggles edit mode** → Task 6 enterEditMode/exitEditMode. - **Live prices keep updating during edit mode** → no change to portfolio.js's `setInterval(mountAndRender, ...)` cadence; edit-mode is purely visual layering. - **On-blur, blocking ticker validation** → Task 7 (`blur` listener; Add button enabled only when `validated && qty > 0 && cost > 0`). - **Avg-cost / bought-on-date toggle** → Task 9 with `onModeChange`. - **Date is a UX helper only** → Task 8 `addPosition` stores only `qty + avg_cost + currency` (no date in localStorage). - **Immediate per-row persist (no Save)** → Task 8 `addPosition` writes localStorage on each Add. - **Two paid-only endpoints** → Tasks 1-4 with paid-gate inspection tests. - **Side-effect: seed ticker_universe** → Task 1 endpoint code + Task 2 side-effect test. - **Historical: walk back to preceding trading day** → Task 3 unit test (`test_fetch_yahoo_historical_walks_back_to_preceding_trading_day`). - **Duplicate ticker → warning, allow proceed** → Task 7 `showDuplicateWarning`; Add button remains enabled. - **× delete by index (handles duplicates correctly)** → Task 10's event delegation uses `data-idx` not by ticker. - **Empty state shows the form** → Task 10 `renderEmpty` rewrite + `pf-empty` class. - **No persistence of dates** → Task 8 (only `qty + avg_cost + currency` in the position object). - **No multi-pie / no save-to-server** → out of scope; no tasks build these. **Type / signature consistency:** - `validate_ticker(symbol, session)` — used in Tasks 1, 2, 4. Consistent. - `get_historical(symbol, date)` — Tasks 3, 4. No `session` parameter (no DB writes). Consistent. - `fetch_yahoo_historical(client, symbol, target_iso) -> tuple[float|None, str|None, str|None]` — Tasks 3, all consistent. - `window.CassandraPortfolio.{loadPie, savePie, mountAndRender}` — used in Tasks 7-10. Already exposed by existing portfolio.js. No new exports needed. **Spec deviation flagged:** - **Provider-down → 502 vs symbol-unknown → 200 ok:false distinction**: the spec preserves both. The plan collapses both into `{ok: false}` (200) because `fetch_yahoo` swallows exceptions and disentangling them post-hoc is fragile. UI difference: the user sees "Symbol not recognised" or "Couldn't validate, try again" depending on whether the JS got a network error vs a parsed JSON ok:false — close enough to the spec's intent. If the engineer prefers the spec's distinction, they can call an unsafe `httpx.AsyncClient` directly inside the endpoint and split exception types. Note in the commit message either way. No spec requirements have zero tasks.