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>
72 lines
2.8 KiB
HTML
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>
|