diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index 00ca9a6..0d5f292 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -1,6 +1,7 @@
# TIP Changelog
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
+{"d":"2026-05-14","t":"UI","m":"Price History Chart: replaced 260×60px sparkline with full interactive SVG chart (520×200px). Features: multi-vendor colored polylines with end-point dots, Y-axis labels (USD normalized), X-axis date ticks (MM-DD), horizontal grid lines, hover cursor line + floating tooltip showing all vendor prices for hovered day, vendor legend with click-to-toggle visibility, time range selector (7d/14d/30d) with live re-fetch, current best prices table (FX-normalized to USD). FX normalization: EUR×1.08, GBP×1.27. Supports up to 8 vendors (indigo/orange/green/yellow/blue/red/purple/cyan palette). No API changes — existing GET /api/price-history/:id endpoint already returned price_max/min/avg per vendor per day."}
{"d":"2026-05-14","t":"FEAT","m":"Procurement: 5 neue Intelligence-Sektionen. (E) 🟢 Buy-Now Intel — Top buy_now Reorder Signals aus 211k preberechneten Signalen, filterbar nach Form Factor, Signalstärke-Balken, Preis/Stock-Trend, Gründe als Tooltip. API: GET /api/procurement/reorder-top. (A) 💰 Arbitrage — FX-Preis vs. Competitor-Preis für 59k Equivalenz-Paare mit Preisdaten auf beiden Seiten, normalisiert auf USD (EUR×1.08, GBP×1.27), sortiert nach Ersparnis-%. API: GET /api/procurement/arbitrage. (B) 🖥 Switch Compat — Suche nach Switch-Modell (Cisco, Juniper, Arista etc.), zeigt alle kompatiblen Transceiver mit Preis + Verifikationsmethode. 58k Compatibility-Rows, 429 Switches. API: GET /api/procurement/switch-compat?search=. (C) ⚠️ Supply Squeeze — Multi-Signal-Detektor: 4 parallele Quellen (Preis-Momentum 30d vs 60d, Hype-Phase, AI-Cluster-Transceiver-Nachfrage, Stock-Level-Verteilung). Severity: critical/warning/watch. API: GET /api/procurement/supply-squeeze. (D) 🪦 Dead Stock Revival — 7.297 Dead-Stock-SKUs gegen Hype-Cycle-Phasen: zeigt welche Lagerhüter in Technologieklassen liegen die gerade aufsteigen (ascending hype phases, score >30). API: GET /api/procurement/dead-stock-revival."}
{"d":"2026-05-14","t":"FEAT","m":"Crawler Intelligence: Data Quality panel. New GET /api/scrapers/data-quality endpoint — 4 parallel queries over 200,617 transceiver_verification_evidence rows: (1) coverage breakdown (price 11,366/18,146 = 62%, image 12,333/68%, details 17,085/94%, competitor_match 399/2%, quarantined 1,193); (2) all 10 evidence types with count + avg confidence + product count + last seen; (3) robot/scraper contributions table (17 robots ranked by output); (4) daily activity last 14 days. Dashboard Crawler Intelligence tab: new 🔬 Data Quality section with coverage progress bars (color-coded ≥80% green / ≥50% amber / red), evidence type table, SVG sparkline bar chart for 14-day activity, robot contributions table with live/stale dot indicators."}
{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0–100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class → Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal ● LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."}
diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html
index 006779b..4a899f1 100644
--- a/packages/dashboard/index.html
+++ b/packages/dashboard/index.html
@@ -4191,74 +4191,207 @@ async function openTxDetail(id) {
el('panel-content').insertAdjacentHTML('beforeend', ch);
}).catch(function() {});
- // Async: load price history chart (last 30 days)
- (function loadPriceHistoryPanel(txId) {
- var ph = '
📈 Price History (30 days)
';
- ph += 'Loading…
';
+ // Async: load price history chart — full interactive SVG, multi-vendor, axes, hover tooltip
+ (function initPriceHistory(txId) {
+ var W = 520, H = 200, PL = 58, PR = 12, PT = 12, PB = 28;
+ var COLORS = ['#6366f1','#f97316','#10b981','#f59e0b','#3b82f6','#ef4444','#a855f7','#06b6d4'];
+ var FX = { USD: 1.0, EUR: 1.08, GBP: 1.27 };
+ var plotW = W - PL - PR, plotH = H - PT - PB;
+
+ var ph = ''
+ + '📈 Price History '
+ + ''
+ + [7,14,30].map(function(d){ return ''; }).join('')
+ + '
'
+ + ''
+ + '
Loading…
'
+ + '
'
+ + '
'
+ + ''
+ + '';
el('panel-content').insertAdjacentHTML('beforeend', ph);
- api('/api/price-history/' + txId + '?days=30').then(function(phd) {
- var inner = document.getElementById('price-history-inner');
+
+ window._ph = { days: 30, hidden: new Set(), data: null };
+
+ window.phRange = function(d) {
+ window._ph.days = d;
+ [7,14,30].forEach(function(x) {
+ var b = document.getElementById('ph-b'+x);
+ if (b) { b.style.background = (x===d ? 'var(--accent)' : 'var(--surface2)'); b.style.color = (x===d ? '#fff' : 'var(--text)'); }
+ });
+ phFetch(d);
+ };
+
+ window.phVendor = function(v) {
+ if (window._ph.hidden.has(v)) window._ph.hidden.delete(v); else window._ph.hidden.add(v);
+ if (window._ph.data) phDraw(window._ph.data);
+ };
+
+ function phFetch(days) {
+ var inner = document.getElementById('ph-inner');
+ if (inner) inner.textContent = 'Loading…';
+ api('/api/price-history/' + txId + '?days=' + days)
+ .then(function(d) { window._ph.data = d; phDraw(d); })
+ .catch(function() { var i = document.getElementById('ph-inner'); if (i) i.textContent = 'Price history unavailable.'; });
+ }
+
+ function phDraw(phd) {
+ var inner = document.getElementById('ph-inner');
+ var legEl = document.getElementById('ph-leg');
+ var curEl = document.getElementById('ph-cur');
+ var lblEl = document.getElementById('ph-lbl');
if (!inner) return;
+
var series = phd.series || [];
- if (!series.length) { inner.textContent = 'No price data in the last 30 days.'; return; }
+ if (!series.length) {
+ inner.innerHTML = 'No price data for this period.
';
+ if (legEl) legEl.innerHTML = '';
+ if (curEl) curEl.innerHTML = '';
+ return;
+ }
- // Group series by vendor
- var byVendor = {};
- series.forEach(function(row) {
- var v = row.source_vendor || 'Unknown';
- if (!byVendor[v]) byVendor[v] = [];
- byVendor[v].push(row);
+ // Per-vendor data normalised to USD
+ var byV = {};
+ series.forEach(function(r) {
+ var v = r.source_vendor || 'Unknown';
+ if (!byV[v]) byV[v] = [];
+ var fx = FX[r.currency] || 1;
+ byV[v].push({ day: (r.day || '').substring(0, 10), avg: +r.price_avg * fx, min: +r.price_min * fx, orig: +r.price_avg, cur: r.currency });
+ });
+ var vendors = Object.keys(byV).slice(0, 8);
+
+ // All unique days sorted
+ var dayMap = {};
+ series.forEach(function(r) { dayMap[(r.day || '').substring(0, 10)] = 1; });
+ var allDays = Object.keys(dayMap).sort();
+ var xN = allDays.length;
+ if (!xN) { inner.textContent = 'No data.'; return; }
+
+ // Y range across visible vendors
+ var vis = vendors.filter(function(v) { return !window._ph.hidden.has(v); });
+ var vals = [];
+ (vis.length ? vis : vendors).forEach(function(v) { byV[v].forEach(function(p) { if (p.avg > 0) vals.push(p.avg); }); });
+ if (!vals.length) { inner.textContent = 'No data.'; return; }
+
+ var yMn = Math.min.apply(null, vals), yMx = Math.max.apply(null, vals);
+ var pad = (yMx - yMn) * 0.08 || yMx * 0.08 || 1;
+ yMn = Math.max(0, yMn - pad); yMx += pad;
+ var yR = yMx - yMn || 1;
+
+ function tx(i) { return PL + (xN < 2 ? plotW / 2 : (i / (xN - 1)) * plotW); }
+ function ty(v) { return PT + plotH - ((v - yMn) / yR) * plotH; }
+
+ // Horizontal grid lines + y-labels
+ var grid = '';
+ for (var g = 0; g <= 3; g++) {
+ var gv = yMn + (yR * g / 3), gy = ty(gv).toFixed(1);
+ grid += '';
+ var lbl = gv >= 1000 ? (gv / 1000).toFixed(1) + 'k' : gv.toFixed(gv < 10 ? 2 : gv < 100 ? 1 : 0);
+ grid += '' + lbl + '';
+ }
+
+ // X-axis date labels (max ~6)
+ var xlbl = '', xstep = Math.max(1, Math.floor(xN / 6));
+ for (var xi = 0; xi < xN; xi += xstep) {
+ xlbl += '' + (allDays[xi] || '').substring(5) + '';
+ }
+
+ // Vendor polylines with end-point dots
+ var lines = '';
+ vendors.forEach(function(v, vi) {
+ if (window._ph.hidden.has(v)) return;
+ var col = COLORS[vi % COLORS.length];
+ var pts = byV[v]
+ .map(function(p) { var i = allDays.indexOf(p.day); return (i < 0 || p.avg <= 0) ? null : tx(i).toFixed(1) + ',' + ty(p.avg).toFixed(1); })
+ .filter(Boolean);
+ if (!pts.length) return;
+ if (pts.length === 1) {
+ lines += '';
+ } else {
+ lines += '';
+ var lp = pts[pts.length - 1].split(',');
+ lines += '';
+ }
});
- // Build mini sparklines (SVG-based, per vendor)
- var vendorColors = ['#f97316','#3b82f6','#10b981','#a855f7','#f59e0b','#ef4444','#06b6d4','#84cc16'];
- var vendors = Object.keys(byVendor).slice(0, 8);
+ // Hover cursor line + transparent interaction overlay
+ var hoverEls = ''
+ + '';
- // Collect all values for y-scale
- var allVals = series.map(function(r) { return parseFloat(r.price_avg); }).filter(function(v) { return !isNaN(v) && v > 0; });
- var yMin = Math.min.apply(null, allVals);
- var yMax = Math.max.apply(null, allVals);
- var yRange = yMax - yMin || 1;
+ inner.innerHTML = '';
- // Collect all days for x-scale
- var allDays = [];
- series.forEach(function(r) { if (allDays.indexOf(r.day) === -1) allDays.push(r.day); });
- allDays.sort();
- var xCount = allDays.length || 1;
- var W = 260, H = 60;
+ if (lblEl) lblEl.textContent = '(' + window._ph.days + 'd · USD normalized)';
- var svgLines = '';
- vendors.forEach(function(vendor, vi) {
- var points = byVendor[vendor];
- var coords = points.map(function(p) {
- var xi = allDays.indexOf(p.day);
- var x = Math.round((xi / (xCount - 1 || 1)) * (W - 8) + 4);
- var y = Math.round(H - 4 - ((parseFloat(p.price_avg) - yMin) / yRange) * (H - 8));
- return x + ',' + y;
- }).join(' ');
- svgLines += '';
+ // Vendor legend with click-to-toggle
+ if (legEl) {
+ legEl.innerHTML = vendors.map(function(v, vi) {
+ var col = COLORS[vi % COLORS.length], hidden = window._ph.hidden.has(v);
+ var last = byV[v][byV[v].length - 1];
+ var lv = last ? '~$' + last.avg.toFixed(last.avg < 10 ? 2 : 0) : '';
+ var safe = v.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
+ return ''
+ + ''
+ + '' + esc(v) + ''
+ + '' + lv + '';
+ }).join('');
+ }
+
+ // Current best prices
+ if (curEl && phd.current_prices && phd.current_prices.length) {
+ curEl.innerHTML = 'Best prices (last 7 days)
'
+ + ''
+ + phd.current_prices.slice(0, 6).map(function(r) {
+ var usd = (+r.best_price * (FX[r.currency] || 1)).toFixed(2);
+ return '' + esc(r.source_vendor)
+ + ' $' + usd + ''
+ + (r.currency !== 'USD' ? ' (' + r.currency + ' ' + parseFloat(r.best_price).toFixed(2) + ')' : '')
+ + '';
+ }).join('')
+ + '
';
+ }
+
+ // Hover interaction: vertical line + floating tooltip
+ var hr = document.getElementById('ph-hr');
+ var hl = document.getElementById('ph-hl');
+ var tip = document.getElementById('ph-tip');
+ if (!hr || !hl || !tip) return;
+
+ hr.addEventListener('mousemove', function(e) {
+ var svgEl = hr.closest('svg');
+ if (!svgEl) return;
+ var rect = svgEl.getBoundingClientRect();
+ var scaleX = W / (rect.width || W);
+ var mx = (e.clientX - rect.left) * scaleX;
+ var relX = mx - PL;
+ if (relX < 0 || relX > plotW) { hl.setAttribute('display', 'none'); tip.style.display = 'none'; return; }
+
+ var idx = Math.max(0, Math.min(Math.round((relX / plotW) * (xN - 1)), xN - 1));
+ var hDay = allDays[idx];
+ var hx = tx(idx);
+ hl.setAttribute('x1', hx.toFixed(1)); hl.setAttribute('x2', hx.toFixed(1)); hl.setAttribute('display', 'inline');
+
+ var rows = vendors.map(function(v, vi) {
+ var p = byV[v].find(function(q) { return q.day === hDay; });
+ if (!p || p.avg <= 0) return '';
+ var col = COLORS[vi % COLORS.length];
+ return ''
+ + ''
+ + '' + esc(v) + ''
+ + '$' + p.avg.toFixed(2) + '
';
+ }).filter(Boolean);
+
+ if (!rows.length) { hl.setAttribute('display', 'none'); tip.style.display = 'none'; return; }
+ tip.innerHTML = '' + hDay + '
' + rows.join('');
+ tip.style.display = 'block';
+ tip.style.left = (e.clientX + 14) + 'px';
+ tip.style.top = (e.clientY - tip.offsetHeight / 2) + 'px';
});
+ hr.addEventListener('mouseleave', function() { hl.setAttribute('display', 'none'); tip.style.display = 'none'; });
+ }
- var currency = series[0].currency || 'USD';
- var legend = vendors.map(function(v, i) {
- var last = byVendor[v][byVendor[v].length - 1];
- var lastVal = last ? parseFloat(last.price_min).toFixed(2) : '—';
- return ''
- + ''
- + esc(v) + ' ' + currency + ' ' + lastVal + '';
- }).join('');
-
- var html = ''
- + '' + legend + '
'
- + '' + currency + ' range: '
- + yMin.toFixed(2) + ' – ' + yMax.toFixed(2) + ' · ' + series.length + ' observations
';
- inner.innerHTML = html;
- }).catch(function() {
- var inner = document.getElementById('price-history-inner');
- if (inner) inner.textContent = 'Price history not available.';
- });
+ phFetch(30);
})(t.id);
// Async: load cross-brand equivalences