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 = '' + + '' + + grid + xlbl + lines + hoverEls + ''; - // 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 = '' - + '' - + svgLines + '' - + '
' + 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