feat(dashboard): price history chart on signal-card click

Clicking any signal card opens a modal with a 180-day SVG line chart
per source vendor (multi-line, colour-coded), x-axis dates, y-axis price,
current best price summary. Uses existing /api/price-history/:id endpoint.
No external chart library — pure inline SVG.
This commit is contained in:
Rene Fichtmueller 2026-06-05 22:36:31 +00:00
parent 842a85120b
commit e103a99822

View File

@ -901,6 +901,8 @@
</div> </div>
<div id="ov-movers-inner" class="mt" style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem"></div> <div id="ov-movers-inner" class="mt" style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem"></div>
</div> </div>
<!-- RESEARCH ROBOT (overview) -->
<div class="card mb" id="research-robot-card" style="display:none"></div>
<!-- RESEARCH STATUS --> <!-- RESEARCH STATUS -->
<div class="card mb" id="verification-card"> <div class="card mb" id="verification-card">
<div class="card-label">Data Research Status</div> <div class="card-label">Data Research Status</div>
@ -3519,6 +3521,7 @@ async function loadOverview() {
// Async fire — don't block overview render // Async fire — don't block overview render
loadProcurementPulse(); loadProcurementPulse();
loadResearchRobot();
} }
// SEARCH // SEARCH
@ -8251,7 +8254,7 @@ function renderSignals(filterSig) {
if (r.image_r2_key) { if (r.image_r2_key) {
imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">'; imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">';
} }
return '<div class="signal-card ' + sigClass + '">' return '<div class="signal-card ' + sigClass + '" style="cursor:pointer" onclick="openSignalPriceChart(\''+esc(String(r.transceiver_id||r.id||''))+'\',\''+esc(String(r.standard_name||r.part_number||''))+'\')">'
+ '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">' + '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">'
+ imgHtml + imgHtml
+ '<div style="flex:1;min-width:0">' + '<div style="flex:1;min-width:0">'
@ -10397,6 +10400,28 @@ function exportMoversCSV() {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// B PROCUREMENT PULSE (overview cards) // B PROCUREMENT PULSE (overview cards)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async function loadResearchRobot() {
try {
var d = await api("/api/research-robot");
if (!d.runs || !d.runs.length) return;
var r = d.runs[0], f = r.freshness || {}, dec = r.decision || {};
var card = el("research-robot-card"); if (!card) return;
var total = f.jobsTotal || 0, stale = f.stale || 0;
var escd = (dec.escalated && dec.escalated.length) ? dec.escalated : [];
var disp = r.dispatched || [];
var h = "<div class=\"card-label\">&#129302; Research Robot <span style=\"font-weight:400;color:var(--text-dim);font-size:0.72rem\">letzter Lauf " + esc(new Date(r.run_at).toLocaleString()) + " &middot; " + esc(r.model||"") + "</span></div>";
h += "<div class=\"mt\" style=\"display:flex;gap:1.6rem;flex-wrap:wrap;align-items:baseline;margin-bottom:0.5rem\">";
h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:var(--green)\">" + (total-stale) + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">/" + total + " Jobs frisch</span></div>";
h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:" + (stale>0?"#f59e0b":"var(--green)") + "\">" + stale + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">ueberfaellig</span></div>";
if (escd.length) h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:#ef4444\">" + escd.length + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">eskaliert</span></div>";
h += "</div>";
if (dec.assessment) h += "<div style=\"font-size:0.84rem;margin-bottom:0.4rem\"><b>KI-Urteil (lokales LLM):</b> " + esc(dec.assessment) + "</div>";
if (disp.length) h += "<div style=\"font-size:0.76rem;color:var(--text-dim)\">Neu eingereiht: " + disp.map(esc).join(", ") + "</div>";
if (escd.length) h += "<div style=\"font-size:0.76rem;color:#ef4444;margin-top:0.3rem\">&#9888; Dauerhaft fehlschlagend: " + escd.map(esc).join(", ") + "</div>";
card.innerHTML = h; card.style.display = "";
} catch(e) {}
}
async function loadProcurementPulse() { async function loadProcurementPulse() {
var pulse = el('ov-proc-pulse'); var pulse = el('ov-proc-pulse');
var moversCard = el('ov-movers-card'); var moversCard = el('ov-movers-card');
@ -12224,6 +12249,130 @@ function roiCard(title, val, sub, color) {
+ '</div>'; + '</div>';
} }
/* ── PRICE HISTORY CHART (signal-card click) ──────────────────────────── */
async function openSignalPriceChart(txId, partNumber) {
var modal = document.createElement('div');
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9000;display:flex;align-items:center;justify-content:center;padding:1rem';
modal.innerHTML = '<div style="background:var(--surface);border-radius:12px;padding:1.5rem;max-width:680px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.35);position:relative">'
+ '<button onclick="this.closest('[style*=fixed]').remove()" style="position:absolute;top:0.75rem;right:0.9rem;background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-dim)">×</button>'
+ '<div style="font-weight:700;font-size:1rem;margin-bottom:1rem;color:var(--text-bright)" id="ph-title">Price History — ' + (partNumber || txId) + '</div>'
+ '<div id="ph-body" style="min-height:220px;display:flex;align-items:center;justify-content:center;color:var(--text-dim)">Loading…</div>'
+ '</div>';
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
try {
var tok = (window.loadToken ? window.loadToken() : '') || '';
var d = await (await fetch('/api/price-history/' + txId + '?days=180', { headers: { 'Authorization': 'Bearer ' + tok } })).json();
var tx = d.transceiver || {};
document.getElementById('ph-title').textContent = 'Price History — ' + (tx.part_number || partNumber || txId)
+ (tx.form_factor ? ' (' + tx.form_factor + (tx.speed_gbps ? ' · ' + tx.speed_gbps + 'G' : '') + ')' : '');
var series = d.series || [];
if (!series.length) {
document.getElementById('ph-body').innerHTML = '<div style="color:var(--text-dim);text-align:center;padding:2rem">No price data available for this product.</div>';
return;
}
// Build vendor legend + aggregate to daily median per vendor
var vendors = {}, cur = (d.current_prices || []);
series.forEach(function(r) { if (r.source_vendor) vendors[r.source_vendor] = r.currency; });
var vendorList = Object.keys(vendors).slice(0, 6);
var COLORS = ['#6366f1','#22c55e','#f59e0b','#ef4444','#0ea5e9','#a855f7'];
// Date-bucketed: one entry per day per vendor → collect dates
var dates = [];
var seen = {};
series.forEach(function(r) { var d2 = r.day ? r.day.slice(0,10) : ''; if (d2 && !seen[d2]) { seen[d2]=1; dates.push(d2); } });
dates.sort();
// For each vendor, build a price array indexed by date
var vData = {};
vendorList.forEach(function(v) { vData[v] = {}; });
series.forEach(function(r) {
if (r.source_vendor && vData[r.source_vendor] !== undefined && r.day)
vData[r.source_vendor][r.day.slice(0,10)] = parseFloat(r.price_avg || r.price_min);
});
// Determine Y range
var allPrices = [];
vendorList.forEach(function(v) { Object.values(vData[v]).forEach(function(p) { allPrices.push(p); }); });
var pMin = Math.min.apply(null, allPrices), pMax = Math.max.apply(null, allPrices);
var pRange = pMax - pMin || 1;
var W = 620, H = 200, PAD = { l:52, r:12, t:10, b:36 };
var iW = W - PAD.l - PAD.r, iH = H - PAD.t - PAD.b;
function xOf(i) { return PAD.l + (dates.length < 2 ? iW/2 : i / (dates.length-1) * iW); }
function yOf(p) { return PAD.t + iH - ((p - pMin) / pRange * iH); }
var currency = series[0] ? (series[0].currency || '') : '';
// Grid lines (Y)
var nGridY = 4;
var gridSvg = '';
for (var gi = 0; gi <= nGridY; gi++) {
var gy = PAD.t + gi * iH / nGridY;
var gv = pMax - gi * pRange / nGridY;
gridSvg += '<line x1="' + PAD.l + '" y1="' + gy.toFixed(1) + '" x2="' + (W-PAD.r) + '" y2="' + gy.toFixed(1) + '" stroke="var(--border)" stroke-width="1"/>';
gridSvg += '<text x="' + (PAD.l-5) + '" y="' + (gy+4).toFixed(1) + '" text-anchor="end" font-size="9" fill="var(--text-dim)">'
+ (gv >= 1000 ? (gv/1000).toFixed(1)+'k' : gv.toFixed(0)) + '</text>';
}
// X axis labels (up to 6 evenly spaced)
var xLabels = '';
var nXLabels = Math.min(6, dates.length);
for (var xi = 0; xi < nXLabels; xi++) {
var didx = Math.round(xi * (dates.length-1) / Math.max(nXLabels-1,1));
var lx = xOf(didx);
var ldate = dates[didx] || '';
xLabels += '<text x="' + lx.toFixed(1) + '" y="' + (H-4) + '" text-anchor="middle" font-size="9" fill="var(--text-dim)">' + ldate.slice(5) + '</text>';
}
// Polylines per vendor
var linesSvg = '';
vendorList.forEach(function(v, vi) {
var pts = dates.map(function(d3, di) {
var p = vData[v][d3];
return p !== undefined ? (xOf(di).toFixed(1) + ',' + yOf(p).toFixed(1)) : null;
}).filter(Boolean);
if (!pts.length) return;
linesSvg += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + COLORS[vi % COLORS.length] + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
// Last point dot
var lastPt = pts[pts.length-1].split(',');
linesSvg += '<circle cx="' + lastPt[0] + '" cy="' + lastPt[1] + '" r="3" fill="' + COLORS[vi % COLORS.length] + '"/>';
});
// Legend
var legend = vendorList.map(function(v, vi) {
var lastP = Object.values(vData[v]).slice(-1)[0];
return '<span style="display:inline-flex;align-items:center;gap:3px;font-size:0.68rem;margin-right:0.6rem">'
+ '<span style="display:inline-block;width:12px;height:3px;background:' + COLORS[vi%COLORS.length] + ';border-radius:2px"></span>'
+ esc(v) + (lastP ? ' ' + lastP.toFixed(0) + ' ' + currency : '') + '</span>';
}).join('');
// Current best price
var bestHtml = '';
if (cur.length) {
bestHtml = '<div style="margin-top:0.6rem;font-size:0.72rem;color:var(--text-dim)">Best now: '
+ cur.slice(0,3).map(function(c) { return '<b style="color:var(--text)">' + c.best_price + ' ' + c.currency + '</b> @ ' + esc(c.source_vendor); }).join(' · ')
+ '</div>';
}
document.getElementById('ph-body').innerHTML =
'<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:auto;display:block">'
+ gridSvg + linesSvg + xLabels
+ '<text x="' + (W/2) + '" y="' + (H-2) + '" text-anchor="middle" font-size="9" fill="var(--text-dim)">' + currency + '</text>'
+ '</svg>'
+ '<div style="margin-top:0.4rem;line-height:1.8">' + legend + '</div>'
+ bestHtml;
} catch(e) {
var b = document.getElementById('ph-body');
if (b) b.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Failed to load price history: ' + esc(e.message) + '</div>';
}
}
/* ── END PRICE HISTORY CHART ─────────────────────────────────────────────── */
</script> </script>
<script src="/dashboard/hot-topics.js"></script> <script src="/dashboard/hot-topics.js"></script>
</body> </body>