Files
foodlinkk-command-center/cockpit/static/js/briefing-charts.js
T
Aissa 4dd1b8265f Add live CEO briefing, Wereldexport CRM, halal engine, and realtime cockpit.
Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes.
2026-07-19 18:25:21 +00:00

632 lines
30 KiB
JavaScript

window.BriefingCharts = (function () {
var charts = {};
var typewriterTimer = null;
var typewriterAbort = null;
var lastDigestSig = '';
function destroyAll() {
Object.keys(charts).forEach(function (k) {
if (charts[k]) { charts[k].destroy(); charts[k] = null; }
});
}
function parseContent(content) {
var parts = (content || '').split(/\n---\n/);
var ai = parts[0] || '';
var summary = '', actions = [], longTerm = [];
var sm = ai.match(/##\s*Samenvatting\s*\n([\s\S]*?)(?=##\s*Actiepunten|##\s*Lange termijn|$)/i);
if (sm) summary = sm[1].trim().replace(/\*\*/g, '');
var am = ai.match(/##\s*Actiepunten[^\n]*\n([\s\S]*?)(?=##\s*Lange termijn|$)/i);
if (am) actions = am[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
var lm = ai.match(/##\s*Lange termijn[^\n]*\n([\s\S]*)/i);
if (lm) longTerm = lm[1].split('\n').map(function (l) { return l.replace(/^[-*]\s*/, '').trim(); }).filter(Boolean);
if (!summary && ai.trim()) summary = ai.trim().slice(0, 800).replace(/\*\*/g, '');
return { summary: summary, actions: actions, longTerm: longTerm };
}
function typewriter(el, text, speed) {
if (!el) return;
if (typewriterTimer) clearInterval(typewriterTimer);
el.textContent = '';
if (!text) { el.textContent = 'Klik «Genereer dagrapport» voor je persoonlijke Herman briefing.'; return; }
var i = 0;
typewriterTimer = setInterval(function () {
if (i < text.length) { el.textContent += text.charAt(i); i++; }
else clearInterval(typewriterTimer);
}, speed || 8);
}
function countUp(el, end, prefix, suffix) {
if (!el) return;
prefix = prefix || ''; suffix = suffix || '';
var start = 0, dur = 600, t0 = performance.now();
function step(t) {
var p = Math.min(1, (t - t0) / dur);
var v = Math.round(start + (end - start) * p);
el.textContent = prefix + v.toLocaleString('nl-NL') + suffix;
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function chartColors() {
return {
gold: 'rgba(252, 211, 77, 0.9)', cyan: 'rgba(56, 189, 248, 0.9)',
green: 'rgba(74, 222, 128, 0.9)', red: 'rgba(251, 113, 133, 0.9)',
gray: 'rgba(159, 176, 196, 0.85)', grid: 'rgba(159, 176, 196, 0.15)', text: '#c8d4e0',
};
}
function renderPipeline(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.deals_by_stage || [];
var c = chartColors();
if (charts.pipeline) charts.pipeline.destroy();
charts.pipeline = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.stage || '?'; }), datasets: [{ label: 'EUR', data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.gold, borderRadius: 6 }] },
options: { responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Pipeline per stage', color: c.text } },
scales: { y: { ticks: { color: c.text, callback: function (v) { return '€' + v.toLocaleString('nl-NL'); } }, grid: { color: c.grid } }, x: { ticks: { color: c.text }, grid: { display: false } } } },
});
}
function renderSentiment(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var files = stats.nas_files || [], counts = { positive: 0, neutral: 0, negative: 0 };
files.forEach(function (f) { var s = (f.sentiment_label || 'neutral').toLowerCase(); if (counts[s] !== undefined) counts[s]++; });
if (!files.length) counts.neutral = 1;
var c = chartColors();
if (charts.sentiment) charts.sentiment.destroy();
charts.sentiment = new Chart(canvas, {
type: 'doughnut',
data: { labels: ['Positief', 'Neutraal', 'Negatief'], datasets: [{ data: [counts.positive, counts.neutral, counts.negative], backgroundColor: [c.green, c.gray, c.red], borderWidth: 0 }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'NAS sentiment', color: c.text } } },
});
}
function renderWords(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = (stats.top_words || []).slice(0, 8), c = chartColors();
if (charts.words) charts.words.destroy();
charts.words = new Chart(canvas, {
type: 'bar',
data: { labels: rows.map(function (r) { return r.lemma; }), datasets: [{ data: rows.map(function (r) { return Number(r.total) || 0; }), backgroundColor: c.cyan, borderRadius: 6 }] },
options: { indexAxis: 'y', responsive: true, plugins: { legend: { display: false }, title: { display: true, text: 'Top woorden NAS', color: c.text } },
scales: { x: { ticks: { color: c.text }, grid: { color: c.grid } }, y: { ticks: { color: c.text }, grid: { display: false } } } },
});
}
function renderAgents(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var map = {};
(stats.recent_events || []).forEach(function (e) { var a = e.agent_name || 'other'; map[a] = (map[a] || 0) + 1; });
var labels = Object.keys(map), c = chartColors();
if (charts.agents) charts.agents.destroy();
charts.agents = new Chart(canvas, {
type: 'polarArea',
data: { labels: labels, datasets: [{ data: labels.map(function (k) { return map[k]; }), backgroundColor: [c.gold, c.cyan, c.green, c.red, c.gray] }] },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { color: c.text } }, title: { display: true, text: 'Agent activiteit', color: c.text } },
scales: { r: { ticks: { display: false }, grid: { color: c.grid } } } },
});
}
function eqBars() {
return '<div class="hm-eq"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function sparklineSvg(values, trend) {
if (!values || !values.length) return '';
var min = Math.min.apply(null, values), max = Math.max.apply(null, values);
var range = max - min || 1;
var pts = values.map(function (v, i) {
var x = (i / (values.length - 1 || 1)) * 100;
var y = 100 - ((v - min) / range) * 80 - 10;
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var color = trend === 'down' ? '#fb7185' : '#4ade80';
return '<svg class="hm-spark" viewBox="0 0 100 100" preserveAspectRatio="none"><polyline fill="none" stroke="' + color + '" stroke-width="3" points="' + pts + '"/></svg>';
}
function stockEqBars(trend) {
var cls = trend === 'down' ? ' hm-eq-down' : '';
return '<div class="hm-eq' + cls + '"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></div>';
}
function renderStocksMini(container, stats) {
if (!container) return;
var stocks = (stats.market_stocks || []).slice(0, 4);
var summary = stats.market_summary || {};
if (!stocks.length) {
container.innerHTML = '<a href="/beurs" class="btn btn-sm">Beurs openen →</a>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
return '<a href="/beurs" class="beurs-mini-chip ' + (pct >= 0 ? 'up' : 'down') + '">' +
'<strong>' + (s.symbol || s.name) + '</strong> ' +
(pct >= 0 ? '+' : '') + pct.toFixed(2) + '%</a>';
}).join('') + '<small style="display:block;margin-top:0.35rem;color:#64748b">Gem. ' +
(summary.avg_change_pct || 0) + '% · <a href="/beurs">alle koersen →</a></small>';
}
function renderStocks(container, stats) {
if (!container) return;
var stocks = stats.market_stocks || [];
var summary = stats.market_summary || {};
var meta = document.getElementById('market-updated-at');
if (meta) {
var avg = summary.avg_change_pct;
meta.textContent = stocks.length ? ('Gem. ' + (avg >= 0 ? '+' : '') + Number(avg || 0).toFixed(2) + '% · ' + (summary.quote_count || stocks.length) + ' quotes') : 'Beurs data laden…';
}
if (!stocks.length) {
container.innerHTML = '<p class="empty-state">Beursdata tijdelijk niet beschikbaar — probeer Live data opnieuw.</p>';
return;
}
container.innerHTML = stocks.map(function (s) {
var pct = Number(s.change_pct || 0);
var up = pct >= 0;
var price = s.price != null ? Number(s.price).toFixed(2) : '—';
var cur = s.currency || 'EUR';
return '<div class="hm-stock-card hm-stock-' + (s.trend || (up ? 'up' : 'down')) + '">' +
'<div class="hm-stock-head"><div><strong>' + (s.symbol || '') + '</strong><small>' + (s.name || '') + '</small></div>' +
'<span class="hm-stock-pct ' + (up ? 'up' : 'down') + '">' + (up ? '▲' : '▼') + ' ' + Math.abs(pct).toFixed(2) + '%</span></div>' +
'<div class="hm-stock-price">' + price + ' <small>' + cur + '</small></div>' +
'<div class="hm-stock-chain">' + (s.chain || s.market || '') + '</div>' +
sparklineSvg(s.sparkline || [], s.trend) + stockEqBars(s.trend) + '</div>';
}).join('');
}
function rssTeaserHtml(stats) {
var n = stats.rss_items || (stats.rss_live || []).length || 0;
return '<p class="hm-rss-teaser">' + n + ' RSS artikelen beschikbaar. ' +
'<a href="/?tab=rss">Open RSS tab →</a></p>';
}
function renderFoodHighlights(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderRegulations(container, stats) {
if (!container) return;
var items = stats.regulation_highlights || [];
if (!items.length) {
container.innerHTML = '<p class="empty-state">Regelgeving feeds — klik RSS refresh in Retail 360</p>';
return;
}
container.innerHTML = items.map(function (r) {
var cat = r.category === 'cbs' ? 'CBS' : 'REG';
return '<div class="hm-highlight-item"><span class="hm-cat-badge hm-cat-' + (r.category || 'reg') + '">' + cat + '</span>' +
'<a href="' + (r.link || '#') + '" target="_blank" rel="noopener"><strong>' + (r.title || '') + '</strong></a>' +
'<small>' + (r.feed_name || '') + '</small></div>';
}).join('');
}
function renderTrends(canvas, stats) {
if (!canvas || typeof Chart === 'undefined') return;
var rows = stats.market_trends || [];
var c = chartColors();
if (charts.trends) charts.trends.destroy();
if (!rows.length) return;
charts.trends = new Chart(canvas, {
type: 'bar',
data: {
labels: rows.map(function (r) { return (r.trend_name || '?').slice(0, 18); }),
datasets: [{
label: 'Kans %',
data: rows.map(function (r) { return Math.round(Number(r.opportunity_score || 0) * 100); }),
backgroundColor: [c.green, c.cyan, c.gold, c.purple || '#a855f7'],
borderRadius: 6,
}],
},
options: {
responsive: true,
plugins: { legend: { display: false }, title: { display: true, text: 'Markt trend scores', color: c.text } },
scales: {
y: { max: 100, ticks: { color: c.text, callback: function (v) { return v + '%'; } }, grid: { color: c.grid } },
x: { ticks: { color: c.text, maxRotation: 45 }, grid: { display: false } },
},
},
});
}
function kpiValue(id, stats) {
var summary = stats.market_summary || {};
var best = summary.best_performer || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL');
case 'clients_active': return (stats.clients_active || 0) + ' / ' + (stats.clients_total || stats.clients || 0);
case 'clients_total': return String(stats.clients_total || stats.clients || 0);
case 'crm_partnerships': return String(stats.crm_partnerships || 0);
case 'supermarkets': return (stats.supermarkets || 0).toLocaleString('nl-NL');
case 'wholesalers': return String(stats.wholesalers || 0);
case 'trending_food': return String(trendCount || 0);
case 'rss_items': return String(stats.rss_items || (stats.rss_live || []).length || 0);
case 'rss_bookmarks': return String((stats.rss_bookmarks || []).length || stats.rss_bookmarks_count || 0);
case 'pending_approvals': return String(stats.pending_approvals || 0);
case 'deals': return String(stats.deals || 0);
case 'products': return String(stats.products || 0);
case 'suppliers': return String(stats.suppliers || 0);
case 'nas_docs': return String(stats.nas_docs || 0);
case 'promo_campaigns': return String(stats.promo_campaigns || 0);
case 'market_best': return best.symbol ? (best.symbol + ' ' + Number(best.change_pct || 0).toFixed(1) + '%') : '—';
default: return '—';
}
}
function kpiPct(id, stats) {
var summary = stats.market_summary || {};
var trendCount = (stats.trending_food || stats.food_market_highlights || []).length;
switch (id) {
case 'pipeline': return '72%';
case 'clients_active':
return Math.min(95, Math.round(((stats.clients_active || 0) / Math.max(stats.clients_total || 1, 1)) * 100)) + '%';
case 'crm_partnerships': return '45%';
case 'supermarkets': return '88%';
case 'trending_food': return Math.min(95, trendCount * 10) + '%';
case 'rss_items': return Math.min(95, ((stats.rss_items || 0) / 10)) + '%';
case 'pending_approvals': return '30%';
case 'market_best': return Math.min(95, Math.abs(Number(summary.avg_change_pct || 0)) * 10) + '%';
default: return '50%';
}
}
function renderKpis(container, stats, selectedKpis) {
if (!container) return;
var meta = (window.DashboardLayout && window.DashboardLayout.KPI_META) || {};
var order = selectedKpis || window._dashboardKpis ||
(window.DashboardLayout && window.DashboardLayout.DEFAULT_KPIS) ||
['pipeline', 'clients_active', 'crm_partnerships', 'supermarkets', 'trending_food', 'pending_approvals'];
var items = order.map(function (id) {
var m = meta[id] || { label: id, sub: '', icon: '▪', color: '#94a3b8' };
return {
label: m.label, sub: m.sub, icon: m.icon, color: m.color || '#94a3b8',
pct: kpiPct(id, stats), val: kpiValue(id, stats), link: m.link,
};
});
if (!items.length) {
container.innerHTML = '<p class="empty-state">Geen KPI\'s geselecteerd — klik <strong>Dashboard instellen</strong>.</p>';
return;
}
container.className = 'hm-neo-kpi-row';
container.innerHTML = items.map(function (it) {
var inner = '<div class="hm-neo-kpi"><div class="hm-neo-kpi-top">' +
'<div class="hm-neo-ring" style="--ring-color:' + it.color + ';--ring-pct:' + it.pct + '"><div class="hm-neo-ring-inner">' + it.icon + '</div></div>' +
'<div><div class="hm-neo-kpi-label">' + it.label + '</div><div class="hm-neo-kpi-sub">' + it.sub + '</div></div></div>' +
'<div class="hm-neo-kpi-value">' + it.val + '</div>' + eqBars() + '</div>';
return it.link ? '<a href="' + it.link + '" class="hm-neo-kpi-link">' + inner + '</a>' : inner;
}).join('');
}
function renderRssFeed(container, stats) {
if (!container) return;
var items = stats.rss_live || stats.trending_food || stats.food_market_highlights || [];
var count = stats.rss_items || items.length;
var head = '<div class="hm-rss-head"><span class="hm-live-dot"></span> ' + count + ' items in database' +
' · <button type="button" class="btn btn-sm" id="btn-rss-refresh-dash">↻ RSS ophalen</button>' +
' · <a href="/marketing">Marketing Hub →</a></div>';
if (!items.length) {
container.innerHTML = head + '<p class="empty-state">Geen RSS items — klik <strong>RSS ophalen</strong> of ga naar <a href="/marketing">Marketing Hub</a>.</p>';
var btn = container.querySelector('#btn-rss-refresh-dash');
if (btn) btn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
return;
}
container.innerHTML = head + '<div class="hm-rss-list">' + items.slice(0, 20).map(function (r) {
var when = (r.published_at || '').substring(0, 16).replace('T', ' ');
var cat = (r.category || 'feed').toUpperCase();
return '<a href="' + (r.link || '#') + '" target="_blank" rel="noopener" class="hm-rss-item">' +
'<span class="hm-rss-cat">' + cat + '</span>' +
'<span class="hm-rss-body"><strong>' + (r.title || '') + '</strong>' +
'<small>' + (r.feed_name || 'RSS') + (when ? ' · ' + when : '') + '</small></span></a>';
}).join('') + '</div>';
var refreshBtn = container.querySelector('#btn-rss-refresh-dash');
if (refreshBtn) refreshBtn.addEventListener('click', function () {
if (window._refreshRssDash) window._refreshRssDash();
});
}
function renderRetail(container, stats) {
if (!container) return;
container.innerHTML = rssTeaserHtml(stats);
}
function renderMilestones(container, stats) {
if (!container) return;
var ms = stats.milestones_pending || [];
if (!ms.length) {
container.innerHTML = '<p class="empty-state">Nog geen milestones — voeg toe via <a href="/retail">Retail 360 → Sales tab</a></p>';
return;
}
container.innerHTML = ms.map(function (m) {
return '<div class="milestone-item"><span class="milestone-dot"></span><div><strong>' + (m.title || '') + '</strong><br><small>' + (m.chain || '') + ' ' + (m.store_name || '') + '</small></div></div>';
}).join('');
}
function renderExecutiveSummary(container, stats) {
if (!container) return;
var items = [
{ icon: '🤝', label: 'Actieve klanten', val: (stats.clients_active || 0) + ' van ' + (stats.clients_total || stats.clients || 0), link: '/clients' },
{ icon: '💰', label: 'Pipeline', val: '€' + Math.round(stats.pipeline_eur || 0).toLocaleString('nl-NL'), link: '/deals' },
{ icon: '🏪', label: 'CRM partnerships', val: stats.crm_partnerships || 0, link: '/retail' },
{ icon: '🛒', label: 'Supermarkten DB', val: (stats.supermarkets || 0).toLocaleString('nl-NL'), link: '/retail' },
{ icon: '📦', label: 'Groothandels', val: stats.wholesalers || 0, link: '/retail' },
{ icon: '✓', label: 'Goedkeuringen open', val: stats.pending_approvals || 0, link: '/' },
{ icon: '📁', label: 'Actieve promo\'s', val: stats.promo_campaigns || '—', link: '/marketing?tab=reclame' },
{ icon: '📊', label: 'NAS documenten', val: stats.nas_docs || 0, link: '/documents' },
];
var trending = (stats.trending_food || stats.food_market_highlights || []).length;
var rssN = stats.rss_items || (stats.rss_live || []).length || trending;
var ms = (stats.milestones_pending || []).slice(0, 3);
var html = '<div class="hm-exec-grid">' + items.map(function (it) {
return '<a href="' + it.link + '" class="hm-exec-item"><span class="hm-exec-icon">' + it.icon + '</span>' +
'<div><strong>' + it.label + '</strong><div class="hm-exec-val">' + it.val + '</div></div></a>';
}).join('') + '</div>';
if (rssN) {
html += '<p class="hm-rss-teaser" style="margin-top:1rem">' + rssN + ' RSS feeds — <a href="/?tab=rss">bekijk in RSS tab →</a></p>';
}
if (ms.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#94a3b8">Open milestones</h4><ul class="hm-exec-list">';
ms.forEach(function (m) {
html += '<li>' + (m.title || '') + ' · ' + (m.chain || '') + ' ' + (m.store_name || '') + '</li>';
});
html += '</ul>';
}
var pending = stats.pending_approval_requests || [];
if (pending.length) {
html += '<h4 style="margin:1rem 0 0.5rem;font-size:0.8rem;color:#f59e0b">⏳ Open goedkeuringen</h4><ul class="hm-exec-list">';
pending.forEach(function (p) {
html += '<li><strong>@' + (p.agent_key || '') + '</strong> · ' + (p.title || '') + '</li>';
});
html += '</ul>';
}
html += '<p class="hm-exec-footer" style="margin-top:1rem;font-size:0.8rem;color:#64748b">' +
'<a href="http://10.4.7.18:3001/aissa/foodlinkk-command-center" target="_blank" rel="noopener">Gitea</a> · ' +
'<a href="/ops">IT Ops</a> · <a href="/packaging">Packaging</a> · ' +
'<a href="/marketing?tab=publish">Automatisering</a> · ' +
'<a href="/marketing">Marketing Hub</a> · <a href="/analytics">Analytics</a></p>';
container.innerHTML = html;
}
function renderText(root, content, createdAt) {
if (!root) return;
var parsed = parseContent(content);
var meta = document.getElementById('briefing-meta');
if (meta && createdAt) meta.textContent = 'Laatst bijgewerkt: ' + String(createdAt).substring(0, 19).replace('T', ' ');
var sumEl = document.getElementById('briefing-summary-text');
if (sumEl && parsed.summary) {
var lines = parsed.summary.split(/(?<=[.!?])\s+/).filter(Boolean);
typewriterLiveDigest(sumEl, {
sections: [{ brand: 'platform', title: 'Herman rapport', icon: '📝', lines: lines.length ? lines : [parsed.summary] }],
}, { charMs: 10, linePause: 90 });
}
var actEl = document.getElementById('briefing-actions-list');
if (actEl) typewriterListItems(actEl, parsed.actions.length ? parsed.actions : ['Genereer dagrapport voor actiepunten'], { charMs: 8 });
var longEl = document.getElementById('briefing-longterm-list');
if (longEl) setTimeout(function () {
typewriterListItems(longEl, parsed.longTerm.length ? parsed.longTerm : ['Halal kant-en-klaar partnerships schalen'], { charMs: 8 });
}, 300);
}
function escHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function digestSignature(digest) {
if (!digest) return '';
return JSON.stringify({
sections: digest.sections || [],
actions: digest.actions || [],
long_term: digest.long_term || [],
});
}
function fillDigestInstant(container, digest) {
var sections = (digest && digest.sections) || [];
if (!sections.length) {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function (l) { return '<li>' + escHtml(l) + '</li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5>' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
}
function typewriterLiveDigest(container, digest, opts) {
if (!container) return;
opts = opts || {};
var charMs = opts.charMs || 14;
var linePause = opts.linePause || 120;
var sectionPause = opts.sectionPause || 200;
if (typewriterAbort) { typewriterAbort(); typewriterAbort = null; }
if (typewriterTimer) { clearInterval(typewriterTimer); typewriterTimer = null; }
var sections = (digest && digest.sections) || [];
if (!sections.length) {
var summary = (digest && digest.summary) || '';
if (summary) {
typewriter(container, summary, charMs);
} else {
container.innerHTML = '<p class="muted">Live samenvatting laden…</p>';
}
return;
}
container.innerHTML = sections.map(function (sec) {
var lines = (sec.lines || []).filter(Boolean);
var lis = lines.length
? lines.map(function () { return '<li class="tw-line"></li>'; }).join('')
: '<li class="muted">Geen data</li>';
return '<div class="live-digest-section brand-' + escHtml(sec.brand || 'shared') + '">' +
'<h5 class="tw-heading">' + escHtml((sec.icon || '') + ' ' + (sec.title || '')) + '</h5>' +
'<ul class="live-digest-list">' + lis + '</ul></div>';
}).join('');
var queue = [];
container.querySelectorAll('.live-digest-section').forEach(function (secEl, si) {
var lines = (sections[si] && sections[si].lines) || [];
var lis = secEl.querySelectorAll('li.tw-line');
for (var i = 0; i < lis.length; i++) {
queue.push({ el: lis[i], text: lines[i] || '', section: si });
}
});
var cancelled = false;
typewriterAbort = function () { cancelled = true; };
container.classList.add('is-typing');
function tick(qi, ci, prevSection) {
if (cancelled) return;
if (qi >= queue.length) {
container.classList.remove('is-typing');
container.querySelectorAll('.is-typing').forEach(function (n) { n.classList.remove('is-typing'); });
return;
}
var item = queue[qi];
var el = item.el;
var text = item.text;
if (prevSection !== item.section && ci === 0) {
setTimeout(function () { if (!cancelled) tick(qi, ci, item.section); }, sectionPause);
return;
}
el.classList.add('is-typing');
container.querySelectorAll('li.is-typing').forEach(function (n) { if (n !== el) n.classList.remove('is-typing'); });
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
setTimeout(function () { tick(qi, ci + 1, item.section); }, charMs);
} else {
el.classList.remove('is-typing');
setTimeout(function () { tick(qi + 1, 0, item.section); }, linePause);
}
}
tick(0, 0, -1);
}
function typewriterListItems(listEl, items, opts) {
if (!listEl || !items || !items.length) return;
opts = opts || {};
var charMs = opts.charMs || 10;
listEl.innerHTML = items.map(function () { return '<li class="tw-line"></li>'; }).join('');
var lis = listEl.querySelectorAll('li.tw-line');
var qi = 0, ci = 0, cancelled = false;
function next() {
if (cancelled || qi >= items.length) return;
var el = lis[qi];
var text = items[qi];
el.classList.add('is-typing');
if (ci < text.length) {
el.textContent = text.substring(0, ci + 1);
ci++;
setTimeout(next, charMs);
} else {
el.classList.remove('is-typing');
qi++; ci = 0;
setTimeout(next, 90);
}
}
next();
return function () { cancelled = true; };
}
function fillListInstant(listEl, items) {
if (!listEl) return;
var rows = (items && items.length) ? items : ['Geen urgente acties — check agents & retail'];
listEl.innerHTML = rows.map(function (t) { return '<li>' + escHtml(t) + '</li>'; }).join('');
}
function renderLiveSummary(digest, pollRefresh) {
if (!digest) return;
var sumEl = document.getElementById('briefing-summary-text');
var actEl = document.getElementById('briefing-actions-list');
var longEl = document.getElementById('briefing-longterm-list');
var meta = document.getElementById('briefing-meta');
var sig = digestSignature(digest);
var unchanged = pollRefresh && sig === lastDigestSig;
if (!unchanged) lastDigestSig = sig;
if (meta) {
var ts = String(digest.updated_at || '').substring(0, 19).replace('T', ' ');
var n = digest.activity_count != null ? ' · ' + digest.activity_count + ' activiteiten' : '';
meta.textContent = 'Live · ververst ' + ts + ' UTC' + n;
}
if (unchanged) {
if (actEl) fillListInstant(actEl, digest.actions || []);
if (longEl) fillListInstant(longEl, digest.long_term || []);
return;
}
var isUpdate = !!lastDigestSig && !pollRefresh;
var twOpts = { charMs: isUpdate ? 8 : 12, linePause: isUpdate ? 70 : 100, sectionPause: isUpdate ? 120 : 180 };
if (sumEl) {
if (pollRefresh) fillDigestInstant(sumEl, digest);
else typewriterLiveDigest(sumEl, digest, twOpts);
}
var actions = digest.actions || [];
if (actEl) {
if (pollRefresh) fillListInstant(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail']);
else typewriterListItems(actEl, actions.length ? actions : ['Geen urgente acties — check agents & retail'], { charMs: isUpdate ? 6 : 8 });
}
var lt = digest.long_term || [];
if (longEl) {
if (pollRefresh) fillListInstant(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen']);
else setTimeout(function () {
typewriterListItems(longEl, lt.length ? lt : ['Halal kant-en-klaar partnerships schalen'], { charMs: isUpdate ? 6 : 8 });
}, isUpdate ? 200 : 400);
}
}
function renderLive(root, stats, vizMode, kpiSelection, liveDigest, opts) {
if (!root || !stats) return;
opts = opts || {};
var mode = vizMode || window._dashboardVizMode || 'neo-bars';
var kpis = kpiSelection || window._dashboardKpis;
if (liveDigest) renderLiveSummary(liveDigest, !!opts.pollRefresh);
else if (stats.live_digest) renderLiveSummary(stats.live_digest, !!opts.pollRefresh);
renderKpis(document.getElementById('briefing-kpis'), stats, kpis);
renderFoodHighlights(document.getElementById('briefing-food-highlights'), stats);
renderExecutiveSummary(document.getElementById('briefing-executive-summary'), stats);
renderRetail(document.getElementById('briefing-retail'), stats);
renderMilestones(document.getElementById('briefing-milestones'), stats);
var actEl = document.getElementById('briefing-activity-log');
if (actEl) {
var log = stats.activity_log || [];
actEl.innerHTML = log.length ? log.slice(0, 12).map(function (e) { return '<li>' + e + '</li>'; }).join('')
: '<li class="muted">Nog geen agent-acties vandaag.</li>';
}
renderPipeline(document.getElementById('chart-pipeline'), stats);
renderWords(document.getElementById('chart-words'), stats);
if (window.VizEngine) {
VizEngine.renderSentiment(mode, document.getElementById('chart-sentiment'), document.getElementById('viz-sentiment-alt'), stats);
VizEngine.renderAgents(mode, document.getElementById('chart-agents'), document.getElementById('viz-agents-alt'), stats);
} else {
renderSentiment(document.getElementById('chart-sentiment'), stats);
renderAgents(document.getElementById('chart-agents'), stats);
}
}
function render(root, stats, content, createdAt, vizMode) {
renderLive(root, stats, vizMode);
renderText(root, content, createdAt);
}
return {
render: render, renderLive: renderLive, renderText: renderText, renderLiveSummary: renderLiveSummary,
renderExecutiveSummary: renderExecutiveSummary, renderRssFeed: renderRssFeed,
renderKpis: renderKpis, destroyAll: destroyAll,
};
})();