phase G: data minimisation + passwordless auth + DeepSeek-first LLM
Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
480fd311c5
commit
6e7f57c6b2
54 changed files with 5005 additions and 916 deletions
|
|
@ -15,6 +15,40 @@
|
|||
</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>
|
||||
// Inject the user's preferred TONE (NOVICE | INTERMEDIATE) into every
|
||||
// HTMX request so AI-generated panels resolve to the right cached
|
||||
// variant. Preference persists in localStorage; see toggle in header.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
function currentTone() {
|
||||
try {
|
||||
var t = localStorage.getItem('cassandra.tone');
|
||||
return (t === 'NOVICE' || t === 'INTERMEDIATE') ? t : 'INTERMEDIATE';
|
||||
} catch (e) { return 'INTERMEDIATE'; }
|
||||
}
|
||||
document.body.addEventListener('htmx:configRequest', function (evt) {
|
||||
evt.detail.parameters.tone = currentTone();
|
||||
});
|
||||
// Reflect the saved value in the toggle on load.
|
||||
var pill = document.getElementById('tone-toggle');
|
||||
if (pill) pill.dataset.tone = currentTone();
|
||||
});
|
||||
|
||||
window.cassandraSetTone = function (newTone) {
|
||||
try { localStorage.setItem('cassandra.tone', newTone); } catch (e) {}
|
||||
var pill = document.getElementById('tone-toggle');
|
||||
if (pill) pill.dataset.tone = newTone;
|
||||
// Trigger a re-fetch of every AI-driven HTMX target on the page.
|
||||
// Easiest: dispatch a custom event that the relevant elements
|
||||
// listen to. Simpler still: fire htmx.trigger on the well-known
|
||||
// panels. We use the simple path.
|
||||
['#dash-header-container', '#log-panel .panel-body',
|
||||
'#indicators-body'].forEach(function (sel) {
|
||||
var el = document.querySelector(sel);
|
||||
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
|
||||
});
|
||||
};
|
||||
</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.
|
||||
|
|
@ -48,6 +82,13 @@
|
|||
<a href="/upload" class="{% if request.url.path == '/upload' %}active{% endif %}">Import</a>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<div id="tone-toggle" class="tone-toggle" data-tone="INTERMEDIATE"
|
||||
role="group" aria-label="Explanation level">
|
||||
<button type="button" data-value="NOVICE"
|
||||
onclick="cassandraSetTone('NOVICE')">Novice</button>
|
||||
<button type="button" data-value="INTERMEDIATE"
|
||||
onclick="cassandraSetTone('INTERMEDIATE')">Intermediate</button>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -66,12 +107,110 @@
|
|||
{% block main %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="app-footer"
|
||||
hx-get="/api/health"
|
||||
hx-trigger="load, every 30s"
|
||||
{# Shared glossary tooltip (Novice mode). Single floating element
|
||||
positioned by JS to escape sticky-bar stacking and viewport edges. #}
|
||||
<div id="glossary-tooltip" role="tooltip" hidden></div>
|
||||
<script>
|
||||
(function () {
|
||||
const tip = document.getElementById('glossary-tooltip');
|
||||
let activeEl = null;
|
||||
|
||||
function position(el) {
|
||||
// Measure after content is set so dimensions are accurate.
|
||||
tip.style.left = '0px';
|
||||
tip.style.top = '0px';
|
||||
tip.hidden = false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const tipRect = tip.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
|
||||
// Decide above or below based on available space.
|
||||
const spaceAbove = rect.top - margin;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - margin;
|
||||
let top = (spaceAbove >= tipRect.height || spaceAbove >= spaceBelow)
|
||||
? rect.top - tipRect.height - 6
|
||||
: rect.bottom + 6;
|
||||
|
||||
// Clamp top into the viewport.
|
||||
if (top < margin) top = margin;
|
||||
if (top + tipRect.height > window.innerHeight - margin) {
|
||||
top = window.innerHeight - tipRect.height - margin;
|
||||
}
|
||||
|
||||
// Horizontal: anchor to term's left edge, clamp to viewport.
|
||||
let left = rect.left;
|
||||
if (left + tipRect.width > window.innerWidth - margin) {
|
||||
left = window.innerWidth - tipRect.width - margin;
|
||||
}
|
||||
if (left < margin) left = margin;
|
||||
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = top + 'px';
|
||||
tip.setAttribute('data-visible', '1');
|
||||
}
|
||||
|
||||
function show(el) {
|
||||
const def = el.getAttribute('data-def');
|
||||
if (!def) return;
|
||||
activeEl = el;
|
||||
tip.textContent = def;
|
||||
position(el);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
activeEl = null;
|
||||
tip.removeAttribute('data-visible');
|
||||
tip.hidden = true;
|
||||
}
|
||||
|
||||
// Event delegation with capture so we catch elements HTMX swaps in
|
||||
// after page load.
|
||||
document.addEventListener('mouseover', function (e) {
|
||||
const el = e.target.closest && e.target.closest('.glossary');
|
||||
if (el) show(el);
|
||||
else if (activeEl && !e.target.closest('.glossary')) hide();
|
||||
}, true);
|
||||
document.addEventListener('focusin', function (e) {
|
||||
if (e.target.classList && e.target.classList.contains('glossary')) {
|
||||
show(e.target);
|
||||
}
|
||||
});
|
||||
document.addEventListener('focusout', function (e) {
|
||||
if (e.target.classList && e.target.classList.contains('glossary')) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile / touch: tap to toggle, tap-elsewhere to dismiss.
|
||||
document.addEventListener('click', function (e) {
|
||||
const el = e.target.closest && e.target.closest('.glossary');
|
||||
if (el) {
|
||||
// Re-show (or toggle off if it's the currently-active one).
|
||||
if (activeEl === el) hide();
|
||||
else show(el);
|
||||
e.preventDefault();
|
||||
} else if (activeEl) {
|
||||
hide();
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Hide on scroll / resize so the tooltip doesn't drift away from
|
||||
// its term.
|
||||
window.addEventListener('scroll', hide, true);
|
||||
window.addEventListener('resize', hide);
|
||||
// Hide when HTMX swaps content (term may have been replaced).
|
||||
document.body.addEventListener('htmx:beforeSwap', hide);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<footer class="markets-bar"
|
||||
hx-get="/api/markets-bar?as=html"
|
||||
hx-trigger="load, every 60s"
|
||||
hx-swap="innerHTML"
|
||||
id="ops-footer">
|
||||
<span class="led idle"></span> awaiting status…
|
||||
id="markets-bar">
|
||||
<div class="markets-bar__inner">
|
||||
<div class="markets-bar__list"><span class="empty">awaiting markets…</span></div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<div id="dash-header-container"
|
||||
style="grid-column: 1 / -1;"
|
||||
hx-get="/api/summary/aggregate?as=html"
|
||||
hx-trigger="load, every 300s"
|
||||
hx-trigger="load, every 300s, tone-changed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="empty">loading aggregate read…</div>
|
||||
</div>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<div id="indicators-body"
|
||||
class="panel-body panel-body--scroll"
|
||||
hx-get="/api/indicators/{{ groups[0] }}?as=html"
|
||||
hx-trigger="load"
|
||||
hx-trigger="load, tone-changed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="empty">loading…</div>
|
||||
</div>
|
||||
|
|
@ -47,15 +47,15 @@
|
|||
<section id="portfolio-panel" class="panel">
|
||||
<div class="panel-header">
|
||||
<span class="title">Portfolio</span>
|
||||
<span class="meta">ingest hourly @ :15 UTC</span>
|
||||
<span class="meta">held locally · prices via /api/universe</span>
|
||||
</div>
|
||||
<div class="panel-body"
|
||||
hx-get="/api/portfolios?as=html"
|
||||
hx-trigger="load, every 60s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="empty">loading…</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.js') }}" defer></script>
|
||||
|
||||
<section id="log-panel" class="panel">
|
||||
<div class="panel-header">
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
</div>
|
||||
<div class="panel-body"
|
||||
hx-get="/api/log/latest?as=html"
|
||||
hx-trigger="load, every 300s"
|
||||
hx-trigger="load, every 300s, tone-changed"
|
||||
hx-swap="innerHTML">
|
||||
<div class="empty">awaiting first log…</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cassandra · Login</title>
|
||||
<title>Cassandra · Sign in</title>
|
||||
<script>
|
||||
(function() {
|
||||
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
|
||||
|
|
@ -16,7 +16,12 @@
|
|||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__brand">Cassandra</div>
|
||||
<div class="auth-card__hint">log in to access the dashboard</div>
|
||||
<div class="auth-card__hint">sign in with email</div>
|
||||
|
||||
<p class="auth-card__lede">
|
||||
Enter your email and we'll send you a 6-digit code. No password.
|
||||
First-time visitors get an account; returning visitors get a sign-in.
|
||||
</p>
|
||||
|
||||
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||
|
||||
|
|
@ -25,17 +30,8 @@
|
|||
<label>Email
|
||||
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
<button type="submit">Sign in</button>
|
||||
<button type="submit">Send code</button>
|
||||
</form>
|
||||
|
||||
{% if signup_enabled %}
|
||||
<div class="auth-card__alt">
|
||||
No account? <a href="/signup">Create one →</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,5 @@
|
|||
<div class="dash-header">
|
||||
<div class="dash-header__markets">
|
||||
{% for m in markets %}
|
||||
<div class="mkt {% if m.open %}mkt--open{% else %}mkt--closed{% endif %}">
|
||||
<span class="mkt__dot"></span>
|
||||
<span class="mkt__name">{{ m.name }}</span>
|
||||
<span class="mkt__state">{{ m.label }}</span>
|
||||
<span class="mkt__when">
|
||||
<span class="mkt__when-label">{% if m.open %}closes{% else %}opens{% endif %}</span>
|
||||
<time datetime="{{ m.until.isoformat() }}" title="{{ m.until.isoformat() }}">{{ m.until.strftime("%H:%MZ") }}</time>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{# Markets row moved to the sticky bottom bar (partials/markets_bar.html). #}
|
||||
|
||||
{% if summary %}
|
||||
<div class="dash-header__read">
|
||||
|
|
@ -21,7 +9,7 @@
|
|||
{{ summary.generated_at.strftime("%H:%M UTC") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="dash-header__read-body">{{ summary.content }}</p>
|
||||
<p class="dash-header__read-body">{{ summary.content | glossary(tone) }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="dash-header__read dash-header__read--pending">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
{{ summary.generated_at.strftime("%H:%M UTC") }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="ind-summary__body">{{ summary.content }}</p>
|
||||
<p class="ind-summary__body">{{ summary.content | glossary(tone) }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="ind-summary ind-summary--pending">
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
{% if not log %}
|
||||
<div class="empty">awaiting first generated log</div>
|
||||
{% else %}
|
||||
<div class="log-content">{{ log.content_html | safe }}</div>
|
||||
<div class="log-meta">
|
||||
<div class="log-meta__row">
|
||||
{% if log.tone %}<span class="badge badge--tone-{{ log.tone | lower }}">tone {{ log.tone | lower }}</span>{% endif %}
|
||||
{% if log.analysis %}<span class="badge badge--analysis-{{ log.analysis | lower }}">analysis {{ log.analysis | lower }}</span>{% endif %}
|
||||
{% if log.prompt_version %}<span class="badge badge--ver">prompt v{{ log.prompt_version }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="log-meta__row log-meta__row--dim">
|
||||
generated {{ log.generated_at.strftime("%Y-%m-%d %H:%M UTC") }}
|
||||
· model <span class="neu">{{ log.model }}</span>
|
||||
{% if log.prompt_tokens %} · {{ log.prompt_tokens }}↑/{{ log.completion_tokens }}↓ tokens{% endif %}
|
||||
{% if log.cost_usd is not none %} · ${{ "%.4f"|format(log.cost_usd) }}{% endif %}
|
||||
</div>
|
||||
{# tone / analysis / prompt_version / model / tokens / cost / generated_at
|
||||
are admin metadata. Hidden from the user-facing UI; available via the
|
||||
JSON API and the AICall ledger. The panel header's "generated hourly @
|
||||
:20 UTC" cadence message communicates freshness. #}
|
||||
<div class="log-content"
|
||||
title="Last generated {{ log.generated_at.strftime('%Y-%m-%d %H:%M UTC') }}">
|
||||
{{ log.content_html | safe | glossary(tone) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
29
app/templates/partials/markets_bar.html
Normal file
29
app/templates/partials/markets_bar.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{# Sticky bottom bar — same .mkt chip styling as the old dashboard
|
||||
header, extended with the market's headline index price + 1d change.
|
||||
Refreshed every 60s via HTMX. #}
|
||||
<div class="markets-bar__inner">
|
||||
{% for m in markets %}
|
||||
<div class="mkt {% if m.open %}mkt--open{% else %}mkt--closed{% endif %}"
|
||||
title="{{ m.label }} — {% if m.open %}closes{% else %}opens{% endif %} {{ m.until_iso }}">
|
||||
<span class="mkt__dot"></span>
|
||||
<span class="mkt__name">{{ m.code }}</span>
|
||||
<span class="mkt__state">{{ m.label }}</span>
|
||||
{% if m.index %}
|
||||
<span class="mkt__index">
|
||||
<span class="mkt__index-label">{{ m.index.label }}</span>
|
||||
<span class="mkt__index-price">{{ m.index.price_fmt }}</span>
|
||||
<span class="mkt__index-change {% if m.index.change_1d_pct is not none and m.index.change_1d_pct >= 0 %}pos{% elif m.index.change_1d_pct is not none %}neg{% else %}neu{% endif %}">
|
||||
{%- if m.index.change_1d_pct is not none -%}
|
||||
{{ "%+.2f"|format(m.index.change_1d_pct) }}%
|
||||
{%- else -%}
|
||||
—
|
||||
{%- endif -%}
|
||||
</span>
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="mkt__index mkt__index--empty">—</span>
|
||||
{% endif %}
|
||||
<time class="mkt__when" datetime="{{ m.until_iso }}">{{ m.until_hhmm }}Z</time>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cassandra · Sign up</title>
|
||||
<script>
|
||||
(function() {
|
||||
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
|
||||
catch (e) { document.documentElement.dataset.theme = 'dark'; }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__brand">Cassandra</div>
|
||||
<div class="auth-card__hint">create an account</div>
|
||||
|
||||
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||
|
||||
<form method="post" action="/signup" autocomplete="on">
|
||||
<label>Email
|
||||
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||
</label>
|
||||
<label>Password (min 8 characters)
|
||||
<input type="password" name="password" minlength="8" required>
|
||||
</label>
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-card__alt">
|
||||
Already have an account? <a href="/login">Sign in →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,15 +5,17 @@
|
|||
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
|
||||
<div class="panel-header">
|
||||
<span class="title">Import portfolio (Trading 212 CSV)</span>
|
||||
<span class="meta">no broker credentials required</span>
|
||||
<span class="meta">stays in your browser · never persists server-side</span>
|
||||
</div>
|
||||
|
||||
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
|
||||
<p style="color: var(--muted); font-size: 12.5px; margin: 0 0 14px; line-height: 1.6;">
|
||||
Export your pie from the T212 web app
|
||||
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
|
||||
and drop the CSV here. We resolve each Slice to its Yahoo ticker via
|
||||
a catalogue we maintain in the background.
|
||||
and drop the CSV here. Cassandra resolves each Slice to its Yahoo
|
||||
ticker; the parsed pie is kept in <em>this browser's localStorage</em>
|
||||
only. The server learns just which tickers exist (anonymously) so it
|
||||
can fetch their prices.
|
||||
</p>
|
||||
|
||||
<form id="upload-form" autocomplete="off">
|
||||
|
|
@ -21,137 +23,79 @@
|
|||
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
|
||||
<div class="dz__icon">▱</div>
|
||||
<div class="dz__label">Drop a T212 pie CSV here</div>
|
||||
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 2 MB</div>
|
||||
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 1 MB</div>
|
||||
<div class="dz__filename" id="dz-filename"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top: 14px;">
|
||||
<label for="portfolio-name">Portfolio name (optional)</label>
|
||||
<input type="text" id="portfolio-name" name="portfolio_name"
|
||||
placeholder="auto-derived from CSV's Total row" maxlength="64">
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top: 6px;">
|
||||
<label for="currency">Account currency</label>
|
||||
<select id="currency" name="currency">
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="submit-btn" type="submit" disabled>Import</button>
|
||||
<button id="submit-btn" type="submit" disabled style="margin-top:18px;">Parse</button>
|
||||
</form>
|
||||
|
||||
<div id="result" class="result" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
||||
<script>
|
||||
(function () {
|
||||
var dropZone = document.getElementById('drop-zone');
|
||||
var fileInput = document.getElementById('file-input');
|
||||
var browseLink = document.getElementById('browse-link');
|
||||
var filenameEl = document.getElementById('dz-filename');
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
var form = document.getElementById('upload-form');
|
||||
var resultEl = document.getElementById('result');
|
||||
|
||||
function setFile(file) {
|
||||
if (!file) return;
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
fileInput.files = dt.files;
|
||||
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||||
submitBtn.disabled = false;
|
||||
function ready(fn) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', fn);
|
||||
} else { fn(); }
|
||||
}
|
||||
|
||||
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
||||
fileInput.addEventListener('change', function () {
|
||||
if (fileInput.files[0]) setFile(fileInput.files[0]);
|
||||
});
|
||||
ready(function () {
|
||||
var dropZone = document.getElementById('drop-zone');
|
||||
var fileInput = document.getElementById('file-input');
|
||||
var browseLink = document.getElementById('browse-link');
|
||||
var filenameEl = document.getElementById('dz-filename');
|
||||
var submitBtn = document.getElementById('submit-btn');
|
||||
var form = document.getElementById('upload-form');
|
||||
var resultEl = document.getElementById('result');
|
||||
|
||||
['dragenter', 'dragover'].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dropZone.classList.add('dz--over');
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dropZone.classList.remove('dz--over');
|
||||
});
|
||||
});
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
dropZone.addEventListener('click', function (e) {
|
||||
if (e.target.tagName !== 'A') fileInput.click();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files[0]) return;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Importing…';
|
||||
resultEl.hidden = true;
|
||||
resultEl.className = 'result';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
var name = document.getElementById('portfolio-name').value.trim();
|
||||
if (name) fd.append('portfolio_name', name);
|
||||
fd.append('currency', document.getElementById('currency').value);
|
||||
|
||||
try {
|
||||
var r = await fetch('/api/portfolios/upload', { method: 'POST', body: fd });
|
||||
var data = await r.json();
|
||||
if (!r.ok) {
|
||||
renderError(data.detail || ('HTTP ' + r.status));
|
||||
return;
|
||||
}
|
||||
renderSuccess(data);
|
||||
} catch (err) {
|
||||
renderError(err.message);
|
||||
} finally {
|
||||
submitBtn.textContent = 'Import';
|
||||
function setFile(file) {
|
||||
if (!file) return;
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
fileInput.files = dt.files;
|
||||
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
|
||||
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
||||
fileInput.addEventListener('change', function () {
|
||||
if (fileInput.files[0]) setFile(fileInput.files[0]);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dropZone.classList.add('dz--over');
|
||||
});
|
||||
});
|
||||
['dragleave', 'drop'].forEach(function (ev) {
|
||||
dropZone.addEventListener(ev, function (e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dropZone.classList.remove('dz--over');
|
||||
});
|
||||
});
|
||||
dropZone.addEventListener('drop', function (e) {
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
dropZone.addEventListener('click', function (e) {
|
||||
if (e.target.tagName !== 'A') fileInput.click();
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
if (!fileInput.files[0]) return;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Parsing…';
|
||||
// CassandraPortfolio is exposed by /static/js/portfolio.js.
|
||||
var ok = await window.CassandraPortfolio.handleUpload(form, fileInput.files[0], resultEl);
|
||||
submitBtn.textContent = ok ? 'Parsed' : 'Parse';
|
||||
submitBtn.disabled = !ok;
|
||||
});
|
||||
});
|
||||
|
||||
function fmt(n) {
|
||||
return (n === null || n === undefined) ? '—' : Number(n).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
function renderSuccess(d) {
|
||||
var unmappedTxt = d.unmapped && d.unmapped.length
|
||||
? '<div class="result__warn"><strong>' + d.unmapped.length + ' unmapped slice(s):</strong> '
|
||||
+ d.unmapped.map(function(s) { return '<code>' + s + '</code>'; }).join(', ')
|
||||
+ ' — these won’t get live prices until the catalogue is extended.</div>'
|
||||
: '<div class="result__row neu">All slices resolved to Yahoo tickers.</div>';
|
||||
resultEl.className = 'result result--ok';
|
||||
resultEl.innerHTML =
|
||||
'<div class="result__head">▸ Imported <strong>' + d.portfolio_name + '</strong>'
|
||||
+ (d.is_new_portfolio ? ' <span class="result__tag">new</span>' : ' <span class="result__tag">new snapshot</span>')
|
||||
+ '</div>'
|
||||
+ '<div class="result__grid">'
|
||||
+ '<div><div class="k">Positions</div><div class="v">' + d.positions + '</div></div>'
|
||||
+ '<div><div class="k">Invested</div><div class="v">' + fmt(d.invested) + '</div></div>'
|
||||
+ '<div><div class="k">Value</div><div class="v">' + fmt(d.value) + '</div></div>'
|
||||
+ '<div><div class="k">Result</div><div class="v ' + (d.result >= 0 ? 'pos' : 'neg') + '">'
|
||||
+ (d.result >= 0 ? '+' : '') + fmt(d.result) + '</div></div>'
|
||||
+ '</div>'
|
||||
+ unmappedTxt
|
||||
+ '<div class="result__row"><a href="/">Back to dashboard →</a></div>';
|
||||
resultEl.hidden = false;
|
||||
}
|
||||
function renderError(msg) {
|
||||
resultEl.className = 'result result--err';
|
||||
resultEl.innerHTML = '<div class="result__head">✕ Import failed</div><div class="result__row">'
|
||||
+ String(msg).replace(/[<>]/g, '') + '</div>';
|
||||
resultEl.hidden = false;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
48
app/templates/verify.html
Normal file
48
app/templates/verify.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cassandra · Verify email</title>
|
||||
<script>
|
||||
(function() {
|
||||
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
|
||||
catch (e) { document.documentElement.dataset.theme = 'dark'; }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-shell">
|
||||
<div class="auth-card">
|
||||
<div class="auth-card__brand">Cassandra</div>
|
||||
<div class="auth-card__hint">verify your email</div>
|
||||
|
||||
<p class="auth-card__lede">
|
||||
We sent a {{ ttl_minutes }}-minute code to <strong>{{ email }}</strong>.
|
||||
Enter the 6 digits below to finish signing in.
|
||||
</p>
|
||||
|
||||
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||
{% if sent %}<div class="auth-info">{{ sent }}</div>{% endif %}
|
||||
|
||||
<form method="post" action="/verify" autocomplete="off">
|
||||
<label>Verification code
|
||||
<input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}"
|
||||
minlength="6" maxlength="6" required autofocus
|
||||
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
|
||||
</label>
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/verify/resend" style="margin-top:0.75rem;">
|
||||
<button type="submit" class="auth-card__resend">Resend code</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-card__alt">
|
||||
Wrong email? <a href="/logout">Start over →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue