365 lines
14 KiB
JavaScript
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
// Restore markdown-safe chars
|
|
html = html.replace(/> /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();
|
|
}
|
|
})();
|