read.markets/app/templates/base.html
Giorgio Gilestro 8a155ef157 phase B (2/2): CSV upload endpoint + drag-drop UI
Completes Phase B. The full alternative-onboarding flow is now end-to-end:
drop a T212 pie CSV → parser → InstrumentMap resolver → PortfolioSnapshot
+ Position rows, all without ever asking the user for broker credentials.

- persist_pie() in app/services/csv_import.py: takes a ParsedPie, resolves
  each Slice via InstrumentMap, writes Portfolio + Snapshot + Position
  rows. Unmapped slices are still persisted using their CSV values and
  surfaced in the response for the UI to warn about.
- POST /api/portfolios/upload: multipart endpoint accepting CSV file +
  optional portfolio_name + currency. 2 MiB cap. Returns import summary.
- /upload page with drag-drop dropzone, file input fallback, and inline
  result panel showing invested/value/result + unmapped-slice warnings.
- New "Import" link in the header nav.

Verified end-to-end against the real T212 export: all 13 positions land
with correct T212 tickers (incl. FPp_EQ for the Paris TotalEnergies
listing the heuristic resolver picks), zero unmapped slices, totals
reconcile to the penny.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:00:42 +01:00

72 lines
2.8 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Cassandra{% endblock %}</title>
{# Apply saved theme before stylesheet renders to avoid a flash. #}
<script>
(function() {
try {
var t = localStorage.getItem('cassandra.theme') || 'dark';
document.documentElement.dataset.theme = t;
} catch (e) { document.documentElement.dataset.theme = 'dark'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
<script>
// Render any <time datetime="..."> in the browser's local timezone.
// Re-runs after every HTMX swap so freshly-loaded news rows pick up too.
function formatLocalTimes() {
document.querySelectorAll('time[datetime]:not([data-local])').forEach(function (t) {
try {
var d = new Date(t.getAttribute('datetime'));
if (isNaN(d.getTime())) return;
var date = d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' });
var time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
t.textContent = date + ' ' + time;
t.title = d.toLocaleString();
t.setAttribute('data-local', '1');
} catch (e) {}
});
}
document.addEventListener('DOMContentLoaded', function () {
formatLocalTimes();
document.body.addEventListener('htmx:afterSwap', formatLocalTimes);
});
</script>
</head>
<body>
<div class="app">
<header class="app-header">
<div class="brand">Cassandra</div>
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
</nav>
<div class="header-right">
<button class="theme-toggle" type="button" aria-label="Toggle theme"
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
<span class="theme-toggle__label"></span>
</button>
<span class="meta">v0.1 · UTC</span>
</div>
</header>
<main class="app-main">
{% block main %}{% endblock %}
</main>
<footer class="app-footer"
hx-get="/api/health"
hx-trigger="load, every 30s"
hx-swap="innerHTML"
id="ops-footer">
<span class="led idle"></span> awaiting status…
</footer>
</div>
</body>
</html>