jaeswift-website/js/sitrep.js

365 lines
14 KiB
JavaScript

/* ─── SITREP: Daily AI Briefing Frontend ─────────── */
(function() {
'use strict';
const API_BASE = '/api/sitrep';
let archiveDates = [];
let currentDate = null;
// ─── DOM Elements ────────────────────────────────
const els = {
dateTitle: document.getElementById('sitrepDateTitle'),
notice: document.getElementById('sitrepNotice'),
noticeText: document.getElementById('sitrepNoticeText'),
cryptoBar: document.getElementById('sitrepCryptoBar'),
briefing: document.getElementById('sitrepBriefing'),
content: document.getElementById('sitrepContent'),
metaSources: document.getElementById('metaSources'),
metaModel: document.getElementById('metaModel'),
metaGenerated: document.getElementById('metaGenerated'),
archiveList: document.getElementById('sitrepArchiveList'),
btnPrev: document.getElementById('sitrepPrev'),
btnNext: document.getElementById('sitrepNext'),
btnGenerate: document.getElementById('sitrepGenerate')
};
// ─── Markdown to HTML ────────────────────────────
function mdToHtml(md) {
if (!md) return '<p class="sitrep-error">No content available.</p>';
let html = md;
// Escape HTML entities (but preserve markdown)
html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Restore markdown-safe chars
html = html.replace(/&gt; /gm, '> '); // blockquotes
// Horizontal rules
html = html.replace(/^---+$/gm, '<hr>');
html = html.replace(/^\*\*\*+$/gm, '<hr>');
// Headers (must be before bold processing)
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold + Italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(?<![\w*])\*([^*\n]+?)\*(?![\w*])/g, '<em>$1</em>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Blockquotes (multi-line support)
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
// Merge consecutive blockquotes
html = html.replace(/<\/blockquote>\n<blockquote>/g, '<br>');
// Unordered lists
html = html.replace(/^[\-\*] (.+)$/gm, '<li>$1</li>');
// Wrap consecutive <li> in <ul>
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
// Ordered lists
html = html.replace(/^\d+\. (.+)$/gm, '<oli>$1</oli>');
html = html.replace(/((?:<oli>.*<\/oli>\n?)+)/g, function(match) {
return '<ol>' + match.replace(/<\/?oli>/g, function(tag) {
return tag.replace('oli', 'li');
}) + '</ol>';
});
// Paragraphs — wrap remaining loose text lines
const lines = html.split('\n');
const result = [];
let inBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) {
if (!inBlock) result.push('');
continue;
}
// Skip if already an HTML block element
if (/^<(h[1-6]|ul|ol|li|blockquote|hr|p|div)/.test(line)) {
result.push(line);
inBlock = /^<(ul|ol|blockquote)/.test(line);
} else if (/<\/(ul|ol|blockquote)>$/.test(line)) {
result.push(line);
inBlock = false;
} else {
result.push('<p>' + line + '</p>');
}
}
return result.join('\n');
}
// ─── Date Formatting ─────────────────────────────
function formatSitrepDate(dateStr) {
if (!dateStr) return 'UNKNOWN DATE';
const d = new Date(dateStr + 'T00:00:00Z');
const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
const day = String(d.getUTCDate()).padStart(2, '0');
const month = months[d.getUTCMonth()];
const year = d.getUTCFullYear();
return `DAILY SITREP // ${day} ${month} ${year} // 0700 HRS`;
}
function formatArchiveDate(dateStr) {
if (!dateStr) return '???';
const d = new Date(dateStr + 'T00:00:00Z');
const months = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
const day = String(d.getUTCDate()).padStart(2, '0');
return `${day} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
}
function formatTimestamp(ts) {
if (!ts) return '—';
const d = new Date(ts);
return d.toUTCString().replace('GMT', 'UTC');
}
// ─── Crypto Ticker ───────────────────────────────
function renderCrypto(crypto) {
if (!crypto || !els.cryptoBar) return;
const tickers = Object.entries(crypto);
if (!tickers.length) {
els.cryptoBar.style.display = 'none';
return;
}
let html = '';
tickers.forEach(([symbol, data], idx) => {
const price = parseFloat(data.price) || 0;
const change = parseFloat(data.change) || 0;
const direction = change >= 0 ? '▲' : '▼';
const cls = change >= 0 ? 'positive' : 'negative';
html += `
<div class="crypto-ticker">
<span class="crypto-symbol">${symbol}</span>
<span class="crypto-price">$${price.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}</span>
<span class="crypto-change ${cls}">${direction} ${change >= 0 ? '+' : ''}${change.toFixed(2)}%</span>
</div>
`;
if (idx < tickers.length - 1) {
html += '<div class="crypto-divider"></div>';
}
});
els.cryptoBar.innerHTML = html;
els.cryptoBar.style.display = 'flex';
}
// ─── Navigation ──────────────────────────────────
function updateNav() {
if (!archiveDates.length || !currentDate) {
if (els.btnPrev) els.btnPrev.classList.add('disabled');
if (els.btnNext) els.btnNext.classList.add('disabled');
return;
}
const dates = archiveDates.map(d => d.date);
const idx = dates.indexOf(currentDate);
if (els.btnPrev) {
if (idx < dates.length - 1) {
els.btnPrev.classList.remove('disabled');
els.btnPrev.onclick = () => loadSitrep(dates[idx + 1]);
} else {
els.btnPrev.classList.add('disabled');
els.btnPrev.onclick = null;
}
}
if (els.btnNext) {
if (idx > 0) {
els.btnNext.classList.remove('disabled');
els.btnNext.onclick = () => loadSitrep(dates[idx - 1]);
} else {
els.btnNext.classList.add('disabled');
els.btnNext.onclick = null;
}
}
}
function updateArchiveHighlight() {
if (!els.archiveList) return;
els.archiveList.querySelectorAll('.sitrep-archive-item').forEach(item => {
item.classList.toggle('active', item.dataset.date === currentDate);
});
}
// ─── Load SITREP ─────────────────────────────────
async function loadSitrep(date) {
if (els.content) {
els.content.innerHTML = '<div class="sitrep-loading">DECRYPTING TRANSMISSION...</div>';
}
try {
const url = date ? `${API_BASE}?date=${date}` : API_BASE;
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
if (els.content) {
els.content.innerHTML = `<div class="sitrep-error">⚠ ${err.error || 'SITREP not available'}</div>`;
}
if (els.cryptoBar) els.cryptoBar.style.display = 'none';
return;
}
const data = await resp.json();
currentDate = data.date;
// Date header
if (els.dateTitle) {
els.dateTitle.textContent = formatSitrepDate(data.date);
}
// Notice
if (els.notice && els.noticeText) {
if (data.notice) {
els.noticeText.textContent = data.notice;
els.notice.classList.add('visible');
} else {
els.notice.classList.remove('visible');
}
}
// Crypto bar
renderCrypto(data.crypto);
// Briefing content
if (els.content) {
els.content.innerHTML = mdToHtml(data.content);
}
// Meta
if (els.metaSources) els.metaSources.textContent = data.sources_used || '—';
if (els.metaModel) els.metaModel.textContent = data.model || '—';
if (els.metaGenerated) els.metaGenerated.textContent = formatTimestamp(data.generated_at);
// Nav
updateNav();
updateArchiveHighlight();
} catch (e) {
console.error('SITREP load error:', e);
if (els.content) {
els.content.innerHTML = '<div class="sitrep-error">⚠ TRANSMISSION FAILED — Unable to reach API</div>';
}
}
}
// ─── Load Archive List ────────────────────────────
async function loadArchive() {
if (!els.archiveList) return;
try {
const resp = await fetch(`${API_BASE}/list`);
if (!resp.ok) return;
const data = await resp.json();
archiveDates = data.dates || [];
if (!archiveDates.length) {
els.archiveList.innerHTML = '<div style="padding:0.5rem;color:rgba(255,255,255,0.2);font-size:0.75rem;">No archived SITREPs yet.</div>';
return;
}
let html = '';
archiveDates.forEach(entry => {
const isActive = entry.date === currentDate ? ' active' : '';
html += `
<div class="sitrep-archive-item${isActive}" data-date="${entry.date}" onclick="window.__loadSitrep('${entry.date}')">
<span class="archive-date">${formatArchiveDate(entry.date)}</span>
<span class="archive-headline">${escapeHtml(entry.headline || '—')}</span>
<span class="archive-sources">${entry.sources_used || 0} SRC</span>
</div>
`;
});
els.archiveList.innerHTML = html;
updateNav();
} catch (e) {
console.error('Archive load error:', e);
}
}
// ─── Generate SITREP ─────────────────────────────
async function generateSitrep() {
if (!els.btnGenerate) return;
els.btnGenerate.disabled = true;
els.btnGenerate.textContent = 'GENERATING...';
try {
const resp = await fetch(`${API_BASE}/generate`, { method: 'POST' });
const data = await resp.json();
if (data.status === 'ok') {
els.btnGenerate.textContent = '✓ GENERATED';
// Reload
setTimeout(() => {
loadSitrep();
loadArchive();
els.btnGenerate.textContent = 'GENERATE NOW';
els.btnGenerate.disabled = false;
}, 1000);
} else {
els.btnGenerate.textContent = '✗ FAILED';
console.error('Generate error:', data);
setTimeout(() => {
els.btnGenerate.textContent = 'GENERATE NOW';
els.btnGenerate.disabled = false;
}, 3000);
}
} catch (e) {
console.error('Generate error:', e);
els.btnGenerate.textContent = '✗ ERROR';
setTimeout(() => {
els.btnGenerate.textContent = 'GENERATE NOW';
els.btnGenerate.disabled = false;
}, 3000);
}
}
// ─── Utility ──────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ─── Public API (for archive clicks) ─────────────
window.__loadSitrep = loadSitrep;
// ─── Init ────────────────────────────────────────
function init() {
// Load today's SITREP
loadSitrep();
// Load archive
loadArchive();
// Generate button
if (els.btnGenerate) {
els.btnGenerate.addEventListener('click', generateSitrep);
}
}
// Run on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();