- Move news_job from hourly to 3x/hour (cron 10,30,50), with a CadencePolicy gate that throttles to active hours (07-21 UTC weekdays at 20 min), off-hours (3 h), weekends (6 h). Keeps the daytime feed fresh without spamming RSS sources overnight. - Tag each headline on ingestion via DeepSeek (BATCH_SIZE=25, max_tokens=4000, json.JSONDecoder().raw_decode + per-row regex recovery for resilient parsing). Vocabulary: 16 tags including new EU / USA / AI / Conflict. NULL tags are picked up automatically on the next news_job run, so back-tagging is implicit rather than a separate migration step. - Tag UI: pill bar above the feed with off → include → exclude cycle on click; shift-click jumps straight to exclude. State persists in localStorage and is injected into /api/news requests via htmx:configRequest. Per-row chips sit to the right of the headline (new 5-column grid: age | source | title | tags | UTC) so vertical density stays high. - Strategic log header bug: model was hallucinating "(Updated 21:30 UTC)" in future tense. Bumped PROMPT_VERSION 6→7, added explicit ban on time-of-day clauses, and supply the actual current UTC time in the user prompt so the model has no need to invent one. Migration 0012 adds headlines.tags (JSON, nullable). Tests cover vocabulary integrity, validation/normalisation, and the JSON-recovery parser (17 tests).
280 lines
11 KiB
HTML
280 lines
11 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>
|
|
// 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();
|
|
// News tag filters — only attach to /api/news requests.
|
|
if ((evt.detail.path || '').indexOf('/api/news') === 0) {
|
|
var inc = newsTags('include');
|
|
var exc = newsTags('exclude');
|
|
if (inc.length) evt.detail.parameters.tags = inc.join(',');
|
|
if (exc.length) evt.detail.parameters.exclude_tags = exc.join(',');
|
|
}
|
|
});
|
|
|
|
// News tag preference: include / exclude sets persisted in
|
|
// localStorage. Click cycles include → exclude → off;
|
|
// shift-click goes straight to exclude.
|
|
function newsTags(kind) {
|
|
try {
|
|
var raw = localStorage.getItem('cassandra.news.' + kind);
|
|
var arr = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(arr) ? arr : [];
|
|
} catch (e) { return []; }
|
|
}
|
|
function setNewsTags(kind, arr) {
|
|
try { localStorage.setItem('cassandra.news.' + kind, JSON.stringify(arr)); }
|
|
catch (e) {}
|
|
}
|
|
function refreshNewsPanels() {
|
|
document.querySelectorAll('[hx-get*="/api/news"]').forEach(function (el) {
|
|
if (window.htmx) window.htmx.trigger(el, 'tags-changed');
|
|
});
|
|
}
|
|
// Event delegation so HTMX-swapped pills work without rebinding.
|
|
document.addEventListener('click', function (e) {
|
|
var el = e.target.closest && e.target.closest('.news-tag');
|
|
if (!el) return;
|
|
e.preventDefault();
|
|
var tag = el.getAttribute('data-tag') || '';
|
|
if (el.classList.contains('news-tag--clear')) {
|
|
setNewsTags('include', []);
|
|
setNewsTags('exclude', []);
|
|
refreshNewsPanels();
|
|
return;
|
|
}
|
|
var inc = newsTags('include');
|
|
var exc = newsTags('exclude');
|
|
var inInc = inc.indexOf(tag);
|
|
var inExc = exc.indexOf(tag);
|
|
if (e.shiftKey) {
|
|
// Shift-click → toggle exclude membership; remove from include.
|
|
if (inInc >= 0) inc.splice(inInc, 1);
|
|
if (inExc >= 0) exc.splice(inExc, 1);
|
|
else exc.push(tag);
|
|
} else {
|
|
// Plain click → cycle: off → include → exclude → off.
|
|
if (inInc >= 0) {
|
|
inc.splice(inInc, 1);
|
|
exc.push(tag);
|
|
} else if (inExc >= 0) {
|
|
exc.splice(inExc, 1);
|
|
} else {
|
|
inc.push(tag);
|
|
}
|
|
}
|
|
setNewsTags('include', inc);
|
|
setNewsTags('exclude', exc);
|
|
refreshNewsPanels();
|
|
});
|
|
// 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.
|
|
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">
|
|
<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>
|
|
</button>
|
|
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
|
{% if cu and cu.user %}
|
|
<span class="user-chip">{{ cu.user.email }} · <a href="/logout">logout</a></span>
|
|
{% elif cu and cu.is_admin %}
|
|
<span class="user-chip">admin · <a href="/logout">logout</a></span>
|
|
{% endif %}
|
|
<span class="meta">v0.1 · UTC</span>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="app-main">
|
|
{% block main %}{% endblock %}
|
|
</main>
|
|
|
|
{# 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="markets-bar">
|
|
<div class="markets-bar__inner">
|
|
<div class="markets-bar__list"><span class="empty">awaiting markets…</span></div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|