12 TDD-style tasks: two backend endpoints (validate + historical), router registration, dashboard markup, and five JS slices building the edit-mode behaviour (toggle → ticker validate → Add → date-mode → delete via delegation). CSS pass and final manual smoke close it out. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
50 KiB
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) + thefetch_yahoo_historicalhelper.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<script>tag.app/static/js/portfolio.js— render hidden × button per row; replace empty-state CTA with the inline add form.app/static/css/cassandra.css— small block for edit-mode visibility + form styling.
Reuse without modification:
app/services/market.fetch_yahoo— single-ticker quote fetcher.app/services/market.Quote,_yahoo_range_covering,YAHOO_CHART,UA— forfetch_yahoo_historical.app/services/ticker_universe.upsert_tickers— to seed the universe on validate.app/services/access.require_paid— the paid gate.window.CassandraPortfolio.{loadPie, savePie, mountAndRender}— already exposed.
Test conventions
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
Project-isolated container; safe. Use unittest.mock.patch / monkeypatch on app.routers.ticker_validate.fetch_yahoo (and a new fetch_yahoo_historical) to avoid real HTTP. DB-touching tests follow the per-test factory pattern from tests/test_referral_conversion.py::_build_session_factory (also used in tests/test_llm_csv_parser.py).
Important: Both new endpoints write to the DB on the validate side-effect (upsert ticker_universe + insert Quote row). ticker_universe.upsert_tickers uses MySQL ON DUPLICATE KEY UPDATE which SQLite can't compile — mirror the mocking pattern used in tests/test_llm_csv_parser.py::test_parse_portfolio_route_falls_through_to_llm.
Task 1: Validate endpoint — happy path + tests
Files:
-
Create:
app/routers/ticker_validate.py -
Test:
tests/test_ticker_validate.py -
Step 1: Write failing tests
Create tests/test_ticker_validate.py:
"""Tests for /api/ticker/validate and /api/ticker/historical."""
from __future__ import annotations
from datetime import datetime, timezone
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory, setup).
Mirrors tests/test_llm_csv_parser.py / tests/test_referral_conversion.py."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
import app.models # noqa: F401
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/tv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
return engine, factory, _setup
@pytest.mark.asyncio
async def test_validate_happy_path(tmp_path, monkeypatch):
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
_, factory, setup = _build_session_factory(tmp_path)
await setup()
# Mock fetch_yahoo to return a successful quote.
async def _fake_yahoo(client, symbol, label, note, anchor=None):
return Quote(
symbol=symbol, source="yahoo", label=label, note=note,
price=172.40, currency="USD", as_of="2026-05-27", changes={},
)
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
# Avoid the MySQL-only upsert on SQLite.
async def _fake_upsert(session, tickers):
return len(list(tickers))
monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)
async with factory() as session:
result = await validate_ticker(symbol="aapl", session=session)
assert result["ok"] is True
assert result["symbol"] == "AAPL"
assert result["price"] == 172.40
assert result["currency"] == "USD"
assert result["as_of"] == "2026-05-27"
- Step 2: Run the test to verify it fails
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py::test_validate_happy_path -v
Expected: FAIL with ImportError: cannot import name 'validate_ticker'.
- Step 3: Create the router with the validate endpoint
Create app/routers/ticker_validate.py:
"""Per-ticker validation + historical-price endpoints.
These power the dashboard's "Add a position" form. Neither endpoint
persists holdings — they wrap the existing Yahoo chart fetcher and
optionally seed anonymous ticker_universe (validate only).
Both endpoints are gated behind ``require_paid`` so they match the rest
of the import surface.
"""
from __future__ import annotations
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_auth
from app.db import get_session, utcnow
from app.logging import get_logger
from app.models import Quote as QuoteModel
from app.services.access import require_paid
from app.services.market import (
UA, YAHOO_CHART, Quote, _yahoo_range_covering, fetch_yahoo,
)
from app.services.ticker_universe import upsert_tickers
log = get_logger("ticker_validate")
router = APIRouter(dependencies=[Depends(require_auth)])
@router.get(
"/ticker/validate",
dependencies=[Depends(require_paid)],
)
async def validate_ticker(
symbol: str,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Live quote for one ticker.
Returns ``{ok: true, symbol, price, currency, as_of}`` on success
or ``{ok: false, error}`` when the symbol isn't recognised. Seeds
ticker_universe + writes a Quote row as a side-effect on success
so the dashboard's /api/universe call picks it up on the next
refresh."""
symbol = symbol.strip().upper()[:32]
if not symbol:
return {"ok": False, "error": "symbol required"}
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
quote = await fetch_yahoo(client, symbol, symbol, "")
if quote.error or quote.price is None:
log.info("ticker.validate.miss", symbol=symbol,
error=(quote.error or "no price")[:120])
return {"ok": False, "error": "Symbol not recognised"}
# Side-effect: seed the universe + write the quote so /api/universe
# has data on the next minute-cycle refresh.
await upsert_tickers(session, [symbol])
session.add(QuoteModel(
symbol=quote.symbol, source=quote.source, label=quote.label,
group_name="universe", price=quote.price, currency=quote.currency,
as_of=quote.as_of, changes=quote.changes or None, error=None,
fetched_at=utcnow(),
))
await session.commit()
return {
"ok": True,
"symbol": quote.symbol,
"price": quote.price,
"currency": quote.currency,
"as_of": quote.as_of,
}
- Step 4: Run the test to verify it passes
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py::test_validate_happy_path -v
Expected: PASS.
- Step 5: Commit
git add app/routers/ticker_validate.py tests/test_ticker_validate.py
git commit -m "ticker-validate: add /api/ticker/validate endpoint"
Context for this task
- Working directory:
/home/gg/mydocker_images/products/read.markets. Branchmain. Commit directly. fetch_yahooalready swallows exceptions intoQuote.error. We deliberately do NOT split provider-down vs symbol-unknown — both return{ok: false}with a generic message. The spec mentions 502 for provider-down, but UI value is marginal andfetch_yahoo's broad-except makes the distinction fragile. (Document this deviation in the commit message if it changes the engineer's confidence; otherwise proceed.)- If your
git commitis blocked by the auto-mode classifier, leave the tree dirty and report — controller will commit.
Task 2: Validate endpoint — failure path + side-effect tests
Files:
-
Test:
tests/test_ticker_validate.py(append) -
Step 1: Write failing tests
Append to tests/test_ticker_validate.py:
@pytest.mark.asyncio
async def test_validate_unknown_symbol(tmp_path, monkeypatch):
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
_, factory, setup = _build_session_factory(tmp_path)
await setup()
# Mock fetch_yahoo to return a Quote with error and no price.
async def _fake_yahoo(client, symbol, label, note, anchor=None):
return Quote(symbol=symbol, source="yahoo", label=label, note=note,
price=None, currency=None, as_of=None,
error="empty result")
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
async with factory() as session:
result = await validate_ticker(symbol="XYZNOTREAL", session=session)
assert result["ok"] is False
assert "not recognised" in result["error"].lower()
@pytest.mark.asyncio
async def test_validate_empty_symbol_rejects():
from app.routers.ticker_validate import validate_ticker
# Direct call — no session needed because we short-circuit before any DB use.
result = await validate_ticker(symbol=" ", session=None)
assert result["ok"] is False
assert "required" in result["error"].lower()
@pytest.mark.asyncio
async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
"""Side-effect check: on success, the symbol is upserted into the
universe and a Quote row is written."""
from sqlalchemy import select
from app.models import Quote as QuoteModel
from app.routers.ticker_validate import validate_ticker
from app.services.market import Quote
import app.routers.ticker_validate as mod
_, factory, setup = _build_session_factory(tmp_path)
await setup()
upsert_calls: list[list[str]] = []
async def _fake_yahoo(client, symbol, label, note, anchor=None):
return Quote(symbol=symbol, source="yahoo", label=label, note=note,
price=100.0, currency="USD", as_of="2026-05-27", changes={})
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
async def _fake_upsert(session, tickers):
upsert_calls.append(list(tickers))
return len(list(tickers))
monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)
async with factory() as session:
result = await validate_ticker(symbol="MSFT", session=session)
assert result["ok"] is True
assert upsert_calls == [["MSFT"]]
# Quote row was written.
async with factory() as session:
rows = (await session.execute(
select(QuoteModel).where(QuoteModel.symbol == "MSFT")
)).scalars().all()
assert len(rows) == 1
assert rows[0].price == 100.0
assert rows[0].currency == "USD"
- Step 2: Run tests to verify they pass
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
Expected: all 4 tests in the file pass.
- Step 3: Commit
git add tests/test_ticker_validate.py
git commit -m "ticker-validate: cover failure + side-effect paths"
Task 3: Historical endpoint + tests
Files:
-
Modify:
app/routers/ticker_validate.py(appendfetch_yahoo_historical+ endpoint) -
Test:
tests/test_ticker_validate.py(append) -
Step 1: Write failing tests
Append to tests/test_ticker_validate.py:
@pytest.mark.asyncio
async def test_historical_happy_path(monkeypatch):
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
async def _fake_hist(client, symbol, target_iso):
# close, currency, actual_iso
return 185.92, "USD", "2024-01-12"
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
result = await get_historical(symbol="aapl", date="2024-01-15")
assert result["ok"] is True
assert result["close"] == 185.92
assert result["currency"] == "USD"
# 2024-01-15 was a Monday — but our fake says it walked back to Jan 12 (Fri).
assert result["actual_date"] == "2024-01-12"
@pytest.mark.asyncio
async def test_historical_future_date_rejected():
from fastapi import HTTPException
from app.routers.ticker_validate import get_historical
future = "2099-01-01"
with pytest.raises(HTTPException) as exc:
await get_historical(symbol="AAPL", date=future)
assert exc.value.status_code == 400
assert "future" in str(exc.value.detail).lower()
@pytest.mark.asyncio
async def test_historical_bad_date_format_rejected():
from fastapi import HTTPException
from app.routers.ticker_validate import get_historical
with pytest.raises(HTTPException) as exc:
await get_historical(symbol="AAPL", date="not-a-date")
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_historical_no_data(monkeypatch):
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
async def _fake_hist(client, symbol, target_iso):
return None, None, None
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
result = await get_historical(symbol="ZZZNEW", date="2020-01-15")
assert result["ok"] is False
assert "no data" in result["error"].lower()
@pytest.mark.asyncio
async def test_historical_provider_failure(monkeypatch):
from app.routers.ticker_validate import get_historical
import app.routers.ticker_validate as mod
async def _fake_hist(client, symbol, target_iso):
raise httpx.RequestError("connection failed")
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
import httpx # noqa: F401 — used inside _fake_hist above
result = await get_historical(symbol="AAPL", date="2024-01-15")
assert result["ok"] is False
assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower()
@pytest.mark.asyncio
async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch):
"""Unit test for the helper itself: feed a hand-crafted series with a
weekend gap, ask for the Saturday close, expect Friday's close."""
from app.routers.ticker_validate import fetch_yahoo_historical
# Build a fake httpx client that returns a chart payload with a
# Thu-Fri-Mon-Tue series; we ask for Saturday and expect Friday.
thu_ts = int(datetime(2024, 1, 11, tzinfo=timezone.utc).timestamp())
fri_ts = int(datetime(2024, 1, 12, tzinfo=timezone.utc).timestamp())
mon_ts = int(datetime(2024, 1, 15, tzinfo=timezone.utc).timestamp())
payload = {
"chart": {"result": [{
"meta": {"currency": "USD"},
"timestamp": [thu_ts, fri_ts, mon_ts],
"indicators": {"quote": [{"close": [184.0, 185.92, 186.10]}]},
}]}
}
class _FakeResponse:
def __init__(self, data): self._data = data
def json(self): return self._data
def raise_for_status(self): pass
class _FakeClient:
async def get(self, *args, **kwargs):
return _FakeResponse(payload)
close, currency, actual = await fetch_yahoo_historical(
_FakeClient(), "AAPL", "2024-01-13", # a Saturday
)
assert close == 185.92
assert currency == "USD"
assert actual == "2024-01-12"
- Step 2: Run tests to verify they fail
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -k "historical or fetch_yahoo_historical" -v
Expected: FAIL with ImportError for get_historical and fetch_yahoo_historical.
- Step 3: Append the helper + endpoint to
app/routers/ticker_validate.py
Add to app/routers/ticker_validate.py (under the existing imports + after validate_ticker):
async def fetch_yahoo_historical(
client: httpx.AsyncClient,
symbol: str,
target_iso: str,
) -> tuple[float | None, str | None, str | None]:
"""Fetch the close on ``target_iso`` or the nearest preceding trading
day's close (within the available history window).
Returns ``(close, currency, actual_iso)`` or ``(None, None, None)``
when no usable data exists. Raises on provider-level HTTP errors
(the caller wraps these into a friendly ``ok:false`` response).
"""
range_param = _yahoo_range_covering(target_iso)
r = await client.get(
YAHOO_CHART.format(symbol=symbol),
params={"interval": "1d", "range": range_param,
"includePrePost": "false"},
headers=UA,
timeout=15,
)
r.raise_for_status()
result = r.json().get("chart", {}).get("result")
if not result:
return None, None, None
res = result[0]
currency = (res.get("meta") or {}).get("currency")
timestamps = res.get("timestamp") or []
closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
if not series:
return None, None, None
target_dt = datetime.strptime(target_iso, "%Y-%m-%d").replace(tzinfo=timezone.utc)
# Add a 24h buffer so the target day itself is included (Yahoo
# timestamps are at market open, not midnight).
cutoff_ts = int(target_dt.timestamp()) + 86400
selected: tuple[int, float] | None = None
for t, c in series:
if t <= cutoff_ts:
selected = (t, c)
else:
break
if selected is None:
return None, None, None
actual_iso = datetime.fromtimestamp(selected[0], timezone.utc).strftime("%Y-%m-%d")
return selected[1], currency, actual_iso
@router.get(
"/ticker/historical",
dependencies=[Depends(require_paid)],
)
async def get_historical(symbol: str, date: str) -> dict:
"""Historical daily close. If ``date`` is a non-trading day we walk
back to the last preceding trading day and surface ``actual_date``
so the UI can show the user which date we actually used."""
symbol = symbol.strip().upper()[:32]
if not symbol:
return {"ok": False, "error": "symbol required"}
try:
target = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="invalid date format (YYYY-MM-DD)")
if target > datetime.now(timezone.utc).date():
raise HTTPException(status_code=400, detail="date cannot be in the future")
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
try:
close, currency, actual = await fetch_yahoo_historical(client, symbol, date)
except Exception as e:
log.warning("ticker.historical.failed", symbol=symbol,
date=date, error=str(e)[:200])
return {"ok": False, "error": "Couldn't fetch historical price"}
if close is None:
return {"ok": False, "error": "No data for that date"}
return {"ok": True, "close": close, "currency": currency, "actual_date": actual}
- Step 4: Run tests to verify they pass
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
Expected: all tests in the file pass (10 total now).
- Step 5: Commit
git add app/routers/ticker_validate.py tests/test_ticker_validate.py
git commit -m "ticker-validate: add /api/ticker/historical with weekend-walkback"
Task 4: Register the router + paid-gate inspection tests
Files:
-
Modify:
app/main.py(one new import + one include_router line near line 91) -
Test:
tests/test_ticker_validate.py(append) -
Step 1: Write failing tests
Append to tests/test_ticker_validate.py:
def test_validate_route_requires_paid():
"""Static check that the /ticker/validate route is gated by require_paid."""
from app.routers.ticker_validate import router
from app.services.access import require_paid
route = next(
r for r in router.routes
if getattr(r, "path", "") == "/ticker/validate"
)
dep_callables = [d.call for d in route.dependant.dependencies]
assert require_paid in dep_callables
def test_historical_route_requires_paid():
from app.routers.ticker_validate import router
from app.services.access import require_paid
route = next(
r for r in router.routes
if getattr(r, "path", "") == "/ticker/historical"
)
dep_callables = [d.call for d in route.dependant.dependencies]
assert require_paid in dep_callables
- Step 2: Run the gate tests to verify they pass against the existing router
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -k requires_paid -v
Expected: 2 PASS. (No registration needed for the static check.)
- Step 3: Register the router in
app/main.py
Find the existing router-import block (around lines 20-28) and add:
from app.routers import ticker_validate as ticker_validate_router
Find the existing app.include_router block (around lines 88-103) and add (alongside the other /api-prefixed mounts near line 91):
app.include_router(ticker_validate_router.router, prefix="/api", tags=["ticker-validate"])
- Step 4: Smoke-test the full route is reachable
Append one more test to tests/test_ticker_validate.py:
def test_routes_mounted_under_api_prefix():
"""Confirm the router is mounted on the FastAPI app under /api."""
from app.main import app
paths = {getattr(r, "path", "") for r in app.routes}
assert "/api/ticker/validate" in paths
assert "/api/ticker/historical" in paths
- Step 5: Run all ticker-validate tests
docker compose -f docker-compose.test.yml run --rm test pytest tests/test_ticker_validate.py -v
Expected: all 13 tests pass.
- Step 6: Commit
git add app/main.py tests/test_ticker_validate.py
git commit -m "ticker-validate: mount router at /api/ticker/*"
Task 5: Dashboard markup — EDIT button + form scaffolding
Files:
-
Modify:
app/templates/dashboard.html— add EDIT button to the panel header; add the add-position form (hidden); register the new script. -
Step 1: Replace the portfolio-panel block
In app/templates/dashboard.html, find the existing portfolio panel (~line 47-59):
<section id="portfolio-panel" class="panel">
<div class="panel-header">
<span class="title">Portfolio</span>
<span class="meta">held locally · prices via /api/universe</span>
</div>
<div class="panel-body">
<div id="pf-mount">
<div class="empty">loading…</div>
</div>
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
Replace with:
<section id="portfolio-panel" class="panel">
<div class="panel-header">
<span class="title">Portfolio</span>
<span class="meta">held locally · prices via /api/universe</span>
<button type="button" id="pf-edit-btn" class="pf-edit-btn"
title="Add or remove positions" aria-pressed="false">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
<span class="pf-edit-btn__label">Edit</span>
</button>
<button type="button" id="pf-done-btn" class="pf-done-btn" hidden>Done</button>
</div>
<div class="panel-body">
<div id="pf-add-form" class="pf-add-form" hidden>
<div class="pf-add-row">
<label class="pf-add-field">
<span class="pf-add-label">Ticker</span>
<input type="text" id="pf-add-ticker" autocomplete="off"
spellcheck="false" maxlength="32" placeholder="AAPL">
<span id="pf-add-ticker-status" class="pf-add-status"></span>
</label>
<label class="pf-add-field">
<span class="pf-add-label">Quantity</span>
<input type="number" id="pf-add-qty" min="0" step="any" placeholder="100">
</label>
</div>
<div class="pf-add-row pf-add-cost-mode">
<label class="pf-add-radio">
<input type="radio" name="pf-cost-mode" value="avg" checked>
Avg cost per share
</label>
<label class="pf-add-radio">
<input type="radio" name="pf-cost-mode" value="date">
Bought on date
</label>
</div>
<div class="pf-add-row">
<label class="pf-add-field" id="pf-add-date-field" hidden>
<span class="pf-add-label">Acquisition date</span>
<input type="date" id="pf-add-date">
<span id="pf-add-date-status" class="pf-add-status"></span>
</label>
<label class="pf-add-field">
<span class="pf-add-label">Cost per share</span>
<input type="number" id="pf-add-cost" min="0" step="any" placeholder="150.25">
<span id="pf-add-cost-currency" class="pf-add-currency"></span>
</label>
<button type="button" id="pf-add-submit" class="pf-add-submit" disabled>
+ Add
</button>
</div>
<div id="pf-add-warning" class="pf-add-warning" hidden></div>
</div>
<div id="pf-mount">
<div class="empty">loading…</div>
</div>
</div>
</section>
<script src="{{ url_for('static', path='/js/portfolio-sync.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
<script src="{{ url_for('static', path='/js/portfolio_edit.js') }}" defer></script>
- Step 2: Commit
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.jsshows/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:
/* 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
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:
// ---- 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
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):
// ---- 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
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:
// ---- 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
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:renderEmptyand the row-render code) -
Step 1: Identify the row-render call site
grep -n "<tr\|tr class\|td.*ticker\|td.*qty\|<td " app/static/js/portfolio.js | head -20
Find the function that emits <tr> 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 <td> to each row's HTML:
'<td class="pf-row-del-cell">' +
'<button type="button" class="pf-row-del" data-idx="' + i + '" ' +
'title="Remove this position" aria-label="Remove">×</button>' +
'</td>'
(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 <tr>.)
Also add a matching empty <th></th> 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:
function renderEmpty(mount) {
var notice = window._cassandraPortfolioBackupExpired
? '<div class="empty-notice">Your encrypted cloud backup expired. ' +
'Please re-upload your portfolio to refresh it.' +
'</div>'
: '';
var panel = document.getElementById('portfolio-panel');
if (panel) panel.classList.add('pf-empty');
mount.innerHTML =
'<div class="empty" style="padding:16px;">' +
notice +
'Welcome — start by adding a position above, or ' +
'<a href="/settings#import">import a CSV from your broker →</a>' +
'</div>';
// 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:
// 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):
// ---- 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
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:
/* ---------- 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
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
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)
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:
- Click EDIT → form appears, × buttons appear, focus lands in ticker input.
- Type a known symbol → green check + price + currency.
- Enter qty + cost → Add. New row appears. Form clears.
- 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.
- Click × on any row → row vanishes; refresh confirms persistence.
- 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 (
blurlistener; Add button enabled only whenvalidated && qty > 0 && cost > 0). - Avg-cost / bought-on-date toggle → Task 9 with
onModeChange. - Date is a UX helper only → Task 8
addPositionstores onlyqty + avg_cost + currency(no date in localStorage). - Immediate per-row persist (no Save) → Task 8
addPositionwrites 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-idxnot by ticker. - Empty state shows the form → Task 10
renderEmptyrewrite +pf-emptyclass. - No persistence of dates → Task 8 (only
qty + avg_cost + currencyin 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. Nosessionparameter (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) becausefetch_yahooswallows 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 unsafehttpx.AsyncClientdirectly inside the endpoint and split exception types. Note in the commit message either way.
No spec requirements have zero tasks.