From e103a99822c5972348b22a3517a6bb69219ff484 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Fri, 5 Jun 2026 22:36:31 +0000 Subject: [PATCH] feat(dashboard): price history chart on signal-card click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/dashboard/index.html | 151 +++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index dbadcbe..da4f814 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -901,6 +901,8 @@
+ +
Data Research Status
@@ -3519,6 +3521,7 @@ async function loadOverview() { // Async fire — don't block overview render loadProcurementPulse(); + loadResearchRobot(); } // SEARCH @@ -8251,7 +8254,7 @@ function renderSignals(filterSig) { if (r.image_r2_key) { imgHtml = ''; } - return '
' + return '
' + '
' + imgHtml + '
' @@ -10397,6 +10400,28 @@ function exportMoversCSV() { // ═══════════════════════════════════════════════════════════════════════════ // 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 = "
🤖 Research Robot letzter Lauf " + esc(new Date(r.run_at).toLocaleString()) + " · " + esc(r.model||"") + "
"; + h += "
"; + h += "
" + (total-stale) + " /" + total + " Jobs frisch
"; + h += "
0?"#f59e0b":"var(--green)") + "\">" + stale + " ueberfaellig
"; + if (escd.length) h += "
" + escd.length + " eskaliert
"; + h += "
"; + if (dec.assessment) h += "
KI-Urteil (lokales LLM): " + esc(dec.assessment) + "
"; + if (disp.length) h += "
Neu eingereiht: " + disp.map(esc).join(", ") + "
"; + if (escd.length) h += "
⚠ Dauerhaft fehlschlagend: " + escd.map(esc).join(", ") + "
"; + card.innerHTML = h; card.style.display = ""; + } catch(e) {} +} + async function loadProcurementPulse() { var pulse = el('ov-proc-pulse'); var moversCard = el('ov-movers-card'); @@ -12224,6 +12249,130 @@ function roiCard(title, val, sub, color) { + '
'; } + +/* ── 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 = '
' + + '' + + '
Price History — ' + (partNumber || txId) + '
' + + '
Loading…
' + + '
'; + 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 = '
No price data available for this product.
'; + 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 += ''; + gridSvg += '' + + (gv >= 1000 ? (gv/1000).toFixed(1)+'k' : gv.toFixed(0)) + ''; + } + + // 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 += '' + ldate.slice(5) + ''; + } + + // 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 += ''; + // Last point dot + var lastPt = pts[pts.length-1].split(','); + linesSvg += ''; + }); + + // Legend + var legend = vendorList.map(function(v, vi) { + var lastP = Object.values(vData[v]).slice(-1)[0]; + return '' + + '' + + esc(v) + (lastP ? ' ' + lastP.toFixed(0) + ' ' + currency : '') + ''; + }).join(''); + + // Current best price + var bestHtml = ''; + if (cur.length) { + bestHtml = '
Best now: ' + + cur.slice(0,3).map(function(c) { return '' + c.best_price + ' ' + c.currency + ' @ ' + esc(c.source_vendor); }).join(' · ') + + '
'; + } + + document.getElementById('ph-body').innerHTML = + '' + + gridSvg + linesSvg + xLabels + + '' + currency + '' + + '' + + '
' + legend + '
' + + bestHtml; + + } catch(e) { + var b = document.getElementById('ph-body'); + if (b) b.innerHTML = '
Failed to load price history: ' + esc(e.message) + '
'; + } +} +/* ── END PRICE HISTORY CHART ─────────────────────────────────────────────── */