diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md
index 0d5f292..132af45 100644
--- a/CHANGELOG_PENDING.md
+++ b/CHANGELOG_PENDING.md
@@ -7,6 +7,7 @@ Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
{"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()."}
{"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."}
+{"d":"2026-05-14","t":"FEAT","m":"6 neue Dashboard-Features: (A) Price Movers Alert — GET /api/procurement/price-movers?days=N&limit=N, CTE-basiert (cur vs prior period avg per SKU+Vendor), zeigt Top-Gainers und Top-Losers (|delta_pct| >= 2%, obs >= 2). Procurement-Tab Sektion mit Period-Toggle 7d/14d/30d, Export CSV. (B) Executive Overview Pulse — 5 KPI-Karten (Buy Signals, Arbitrage Ops, Supply Alerts, Price Gainers, Losers) über `loadProcurementPulse()`, Top-Movers Mini-Tabelle im Overview, alle clickable → Procurement-Tab. (C) CSV Export — exportMoversCSV() generiert Gainers+Losers als CSV-Download. (D) Vendor Intelligence — GET /api/vendors/intelligence: per-Vendor in letzten 30d (sku_count, price_obs, avg/min/max price, last_seen), Top-6-Anbieter-Banner im Vendors-Tab. (E) Advanced Transceiver Search — Speed-Filter (1G/10G/.../800G), Fiber-Type-Filter (SMF/MMF), 'Verified Only'-Checkbox in Transceivers-Tab; searchTransceivers() übergibt speed_gbps=, fiber_type=, verified=price an GET /api/transceivers. (F) Knowledge Base Browser — neuer Tab KB, GET /api/kb?q=&category=&limit= (Full-Text ILIKE über question/answer/subcategory), Category-Pills, Entry-Cards mit Severity-Badge, Form-Factor/Speed-Tags."}
{"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."}
{"d":"2026-05-14","t":"FEAT","m":"LinkedIn Distribution Status: Blog tab shows DRY_RUN badge, posted/dry_run/skipped/failed counters, history table with live URN links. GET /api/blog/linkedin/history reads blog_linkedin_distribution table + detects DRY_RUN mode from ecosystem config."}
{"d":"2026-05-14","t":"FEAT","m":"MCP Server: 2 new tools — find_equivalences (search 63k+ verified cross-brand mappings with confidence filter, returns FX alternatives + competitor matches formatted for LLM) + get_price_history (392k+ obs, daily series, per-vendor min/max/avg, cheapest source identification). Total: 21 MCP tools."}
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 104d996..9beeb34 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -37,6 +37,7 @@ import { formFactorsRouter } from "./routes/form-factors";
import { tipLlmRouter } from "./routes/tip-llm";
import { equivalencesRouter } from "./routes/equivalences";
import { priceHistoryRouter } from "./routes/price-history";
+import { kbRouter } from "./routes/kb";
const app = express();
@@ -108,6 +109,7 @@ app.use("/api/tip-llm", tipLlmRouter);
app.use("/api/equivalences", equivalencesRouter);
// Price history charts
app.use("/api/price-history", priceHistoryRouter);
+app.use("/api/kb", kbRouter);
// Dashboard (static HTML)
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));
diff --git a/packages/api/src/routes/kb.ts b/packages/api/src/routes/kb.ts
new file mode 100644
index 0000000..000d639
--- /dev/null
+++ b/packages/api/src/routes/kb.ts
@@ -0,0 +1,47 @@
+import { Router, Request, Response } from "express";
+import { pool } from "../db/client";
+
+export const kbRouter = Router();
+
+// GET /api/kb — Knowledge base browser: FAQ + troubleshooting entries
+// ?q=search&category=faq|troubleshooting|known_issue&limit=50
+kbRouter.get("/", async (req: Request, res: Response) => {
+ const q = ((req.query.q as string) || "").trim();
+ const category = (req.query.category as string) || "";
+ const limit = Math.min(parseInt((req.query.limit as string) || "60"), 200);
+
+ try {
+ const [entries, cats] = await Promise.all([
+ pool.query(
+ `SELECT id, category, subcategory, question, answer,
+ applies_to_form_factors, applies_to_speeds, severity, tags
+ FROM knowledge_base
+ WHERE ($1 = '' OR category = $1)
+ AND ($2 = '' OR question ILIKE '%' || $2 || '%'
+ OR answer ILIKE '%' || $2 || '%'
+ OR subcategory ILIKE '%' || $2 || '%')
+ ORDER BY
+ CASE WHEN $2 != '' AND question ILIKE '%' || $2 || '%' THEN 0 ELSE 1 END,
+ category, subcategory, id
+ LIMIT $3`,
+ [category, q, limit]
+ ),
+ pool.query(
+ `SELECT category, COUNT(*)::int AS count
+ FROM knowledge_base
+ GROUP BY category
+ ORDER BY count DESC`
+ ),
+ ]);
+
+ res.json({
+ success: true,
+ entries: entries.rows,
+ categories: cats.rows,
+ total: entries.rows.length,
+ query: q,
+ });
+ } catch (err) {
+ res.status(500).json({ success: false, error: String(err) });
+ }
+});
diff --git a/packages/api/src/routes/procurement.ts b/packages/api/src/routes/procurement.ts
index 442970f..d3af6f3 100644
--- a/packages/api/src/routes/procurement.ts
+++ b/packages/api/src/routes/procurement.ts
@@ -872,3 +872,74 @@ procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) =>
res.status(500).json({ error: String(err) });
}
});
+
+// GET /api/procurement/price-movers — SKUs with biggest price delta vs prior period
+procurementRouter.get("/price-movers", async (req: Request, res: Response) => {
+ const days = Math.min(parseInt(req.query.days as string) || 7, 90);
+ const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
+
+ try {
+ const result = await pool.query(`
+ WITH cur AS (
+ SELECT transceiver_id, source_vendor_id, currency,
+ AVG(price) AS avg_price,
+ COUNT(*) AS obs
+ FROM price_observations
+ WHERE time >= NOW() - INTERVAL '${days} days'
+ AND price > 0 AND COALESCE(is_anomalous, false) = false
+ GROUP BY transceiver_id, source_vendor_id, currency
+ ),
+ prior AS (
+ SELECT transceiver_id, source_vendor_id,
+ AVG(price) AS avg_price
+ FROM price_observations
+ WHERE time >= NOW() - INTERVAL '${days * 2} days'
+ AND time < NOW() - INTERVAL '${days} days'
+ AND price > 0 AND COALESCE(is_anomalous, false) = false
+ GROUP BY transceiver_id, source_vendor_id
+ )
+ SELECT
+ t.id, t.part_number, t.form_factor,
+ t.speed_gbps::text AS speed_gbps,
+ t.standard_name,
+ sv.name AS vendor_name,
+ ROUND(c.avg_price::numeric, 2) AS current_avg,
+ ROUND(p.avg_price::numeric, 2) AS prior_avg,
+ ROUND(((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100)::numeric, 1) AS delta_pct,
+ c.currency,
+ c.obs::int AS observations
+ FROM cur c
+ JOIN prior p ON p.transceiver_id = c.transceiver_id
+ AND p.source_vendor_id = c.source_vendor_id
+ JOIN transceivers t ON t.id = c.transceiver_id
+ JOIN vendors sv ON sv.id = c.source_vendor_id
+ WHERE ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) >= 2
+ AND c.obs::int >= 2
+ ORDER BY ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) DESC
+ LIMIT ${limit * 2}
+ `);
+
+ const rows = result.rows;
+ const gainers = rows.filter((r) => parseFloat(r.delta_pct) > 0).slice(0, limit);
+ const losers = rows.filter((r) => parseFloat(r.delta_pct) < 0).slice(0, limit);
+
+ const avgOf = (arr: typeof gainers, key: string) =>
+ arr.length ? Math.round(arr.reduce((s, r) => s + parseFloat(r[key]), 0) / arr.length * 10) / 10 : 0;
+
+ res.json({
+ success: true,
+ days,
+ gainers,
+ losers,
+ stats: {
+ totalMovers: rows.length,
+ gainersCount: gainers.length,
+ losersCount: losers.length,
+ avgGainPct: avgOf(gainers, "delta_pct"),
+ avgLossPct: avgOf(losers, "delta_pct"),
+ },
+ });
+ } catch (err) {
+ res.status(500).json({ error: String(err) });
+ }
+});
diff --git a/packages/api/src/routes/vendors.ts b/packages/api/src/routes/vendors.ts
index bd15814..13c937a 100644
--- a/packages/api/src/routes/vendors.ts
+++ b/packages/api/src/routes/vendors.ts
@@ -136,3 +136,38 @@ vendorRouter.get("/:id", async (req: Request, res: Response) => {
return res.status(500).json({ success: false, error: "Internal server error" });
}
});
+
+// GET /api/vendors/intelligence — per-vendor price + SKU market stats (last 30d)
+vendorRouter.get("/intelligence", async (_req: Request, res: Response) => {
+ try {
+ const result = await pool.query(`
+ SELECT
+ v.id,
+ v.name,
+ v.type,
+ v.website,
+ COUNT(DISTINCT po.transceiver_id)::int AS sku_count,
+ COUNT(po.id)::int AS price_obs,
+ ROUND(AVG(po.price)::numeric, 2) AS avg_price,
+ ROUND(MIN(po.price)::numeric, 2) AS min_price,
+ ROUND(MAX(po.price)::numeric, 2) AS max_price,
+ MAX(po.time) AS last_seen,
+ (SELECT currency FROM price_observations
+ WHERE source_vendor_id = v.id
+ ORDER BY time DESC LIMIT 1) AS currency
+ FROM vendors v
+ LEFT JOIN price_observations po
+ ON po.source_vendor_id = v.id
+ AND po.time > NOW() - INTERVAL '30 days'
+ AND po.price > 0
+ AND COALESCE(po.is_anomalous, false) = false
+ GROUP BY v.id, v.name, v.type, v.website
+ HAVING COUNT(DISTINCT po.transceiver_id) > 0
+ ORDER BY COUNT(DISTINCT po.transceiver_id) DESC
+ LIMIT 60
+ `);
+ res.json({ success: true, data: result.rows });
+ } catch (err) {
+ res.status(500).json({ success: false, error: String(err) });
+ }
+});
diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html
index 4a899f1..dd3f3d3 100644
--- a/packages/dashboard/index.html
+++ b/packages/dashboard/index.html
@@ -806,6 +806,7 @@
🏭 Stock
💲 Price Comparison
🔀 Equivalences
+ 📚 KB
+
+
+
+
+
🟢
+
Buy Signals
+
—
+
buy_now
+
+
+
💰
+
Arbitrage Ops
+
—
+
price pairs
+
+
+
⚠️
+
Supply Alerts
+
—
+
critical/warning
+
+
+
📈
+
Price Gainers
+
—
+
7-day movers
+
+
+
🔀
+
Equivalences
+
—
+
cross-brand
+
+
+
+
+
+
+ 📈 Top Price Movers
+
+
+
+
Data Research Status
@@ -1029,6 +1073,25 @@
+
+
+
@@ -1063,6 +1126,11 @@
+
+
+
📊 Vendor Market Intelligence (last 30 days)
+
Loading…
+
@@ -1599,6 +1667,7 @@
+
@@ -1657,6 +1726,28 @@
+
+
+
+ Period:
+
+
+
+
+
+
+
+
+
ℹ Reorder Signals basieren auf ABC-Klassifizierung + Preis-Observations-Frequenz. Echte Verkaufsmengendaten → Tab.
@@ -2396,6 +2487,30 @@
+
+
+
+
📚 Knowledge Base
+
Loading…
+
+
+
+
+
+
+
+
+
+
+
Loading knowledge base…
+
+
+
@@ -2767,6 +2882,7 @@ function goToTab(tabName) {
if (tabName === 'switches') searchSwitches();
if (tabName === 'news') loadNews(1);
if (tabName === 'vendors') loadVendors();
+ if (tabName === 'kb' && !window._kbLoaded) loadKB();
if (tabName === 'standards') loadStandardsList();
if (tabName === 'blog') { loadBlogDrafts(); loadSLLInsights(); loadBlogLLMStatus(); loadPostingTime(); }
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
@@ -2946,6 +3062,9 @@ async function loadOverview() {
+ '';
}).join('') || 'No news yet
');
} catch(e) {}
+
+ // Async fire — don't block overview render
+ loadProcurementPulse();
}
// SEARCH
@@ -3712,18 +3831,24 @@ function searchTransceivers() {
var ff = el('tx-ff-filter').value;
var vf = el('tx-vendor-filter').value;
var verifiedF = (el('tx-verified-filter') || {}).value || '';
+ var spd = el('tx-speed-filter') ? el('tx-speed-filter').value : '';
+ var fib = el('tx-fiber-filter') ? el('tx-fiber-filter').value : '';
+ var verOnly = el('tx-verified-only') ? el('tx-verified-only').checked : false;
var params = [];
if (q) params.push('q=' + encodeURIComponent(q));
if (ff) params.push('form_factor=' + encodeURIComponent(ff));
if (vf) params.push('vendor=' + encodeURIComponent(vf));
- if (verifiedF) params.push('verified=' + encodeURIComponent(verifiedF));
+ if (spd) params.push('speed_gbps=' + encodeURIComponent(spd));
+ if (fib) params.push('fiber_type=' + encodeURIComponent(fib));
+ if (verOnly) params.push('verified=price');
+ else if (verifiedF) params.push('verified=' + encodeURIComponent(verifiedF));
params.push('limit=200');
api('/api/transceivers?' + params.join('&')).then(function(data) {
lastTxData = data.data || data.transceivers || [];
// Show result count in search bar placeholder
var total = data.total || lastTxData.length;
- var activeFilter = q || ff || vf || verifiedF;
+ var activeFilter = q || ff || vf || spd || fib || verOnly || verifiedF;
var txSearchEl = el('tx-search');
if (txSearchEl && !activeFilter) txSearchEl.placeholder = 'Filter: Nexus 9300, QSFP28, 400G, coherent… (' + total + ' transceivers total)';
// Show count above table + clear button
@@ -5076,6 +5201,7 @@ async function loadVendors() {
return diff !== 0 ? diff : (a.name || '').localeCompare(b.name || '');
});
filterVendorCards();
+ loadVendorIntelligence();
}
function filterVendorCards() {
@@ -7049,7 +7175,7 @@ var procAiClustersData = [];
var procAiClustersMinTx = 0;
function showProcSection(name) {
- ['signals','reorder-top','arbitrage','switch-compat','supply-squeeze','dead-stock',
+ ['signals','reorder-top','arbitrage','switch-compat','supply-squeeze','dead-stock','movers',
'abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) {
var sec = el('proc-section-' + s);
var btn = el('proc-btn-' + s);
@@ -7065,6 +7191,7 @@ function showProcSection(name) {
if (name === 'switch-compat' && !el('proc-switch-stats').innerHTML) loadSwitchCompatStats();
if (name === 'supply-squeeze' && !el('proc-squeeze-list').querySelector('div.card,table')) loadSupplySqueeze();
if (name === 'dead-stock' && !el('proc-deadstock-list').querySelector('table')) loadDeadStockRevival();
+ if (name === 'movers' && !window._moversLoaded) loadMovers(7);
}
/* ── E: Buy-Now Reorder Intelligence ───────────────────────────────────── */
@@ -9440,6 +9567,301 @@ document.querySelectorAll('.tab').forEach(function(tab) {
}
});
});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// A – PRICE MOVERS
+// ═══════════════════════════════════════════════════════════════════════════
+window._moversData = null;
+window._moversLoaded = false;
+
+async function loadMovers(days) {
+ window._moversData = null;
+ var section = el('proc-section-movers');
+ if (!section) return;
+ var g = el('mv-gainers'), l = el('mv-losers'), stats = el('mv-stats');
+ if (g) g.innerHTML = 'Loading…
';
+ if (l) l.innerHTML = 'Loading…
';
+ if (stats) stats.innerHTML = '';
+ try {
+ var d = await api('/api/procurement/price-movers?days=' + days + '&limit=20');
+ window._moversData = d;
+ window._moversDays = days;
+ window._moversLoaded = true;
+
+ if (stats) {
+ var s = d.stats || {};
+ stats.innerHTML =
+ 'Movers: ' + (s.totalMovers||0) + ''
+ + ' Gainers +' + (s.avgGainPct||0).toFixed(1) + '%'
+ + ' Losers ' + (s.avgLossPct||0).toFixed(1) + '%';
+ }
+
+ function renderMoversTable(rows, container, isGainer) {
+ if (!container) return;
+ if (!rows || rows.length === 0) {
+ container.innerHTML = 'No movers this period
';
+ return;
+ }
+ var html = ''
+ + ''
+ + '| SKU | '
+ + 'Avg Price | '
+ + 'Change | '
+ + 'Vendor | '
+ + '
';
+ rows.forEach(function(r) {
+ var pct = parseFloat(r.delta_pct);
+ var color = isGainer ? 'var(--green)' : 'var(--red)';
+ var sign = pct > 0 ? '+' : '';
+ html += ''
+ + '| ' + esc(r.model_name || r.part_number || String(r.transceiver_id)) + ' | '
+ + '' + (r.cur_avg ? '$' + parseFloat(r.cur_avg).toFixed(2) : '—') + ' | '
+ + '' + sign + pct.toFixed(1) + '% | '
+ + '' + esc(r.vendor_name || '—') + ' | '
+ + '
';
+ });
+ html += '
';
+ buildDOM(container, html);
+ }
+
+ renderMoversTable(d.gainers, g, true);
+ renderMoversTable(d.losers, l, false);
+ } catch(e) {
+ if (g) g.innerHTML = 'Failed to load price movers
';
+ if (l) l.innerHTML = '';
+ }
+}
+
+function setMoversDays(d) {
+ [7,14,30].forEach(function(n) {
+ var btn = el('mv-btn-' + n);
+ if (!btn) return;
+ btn.style.background = (n === d) ? 'var(--accent)' : 'var(--surface2)';
+ btn.style.color = (n === d) ? '#fff' : '';
+ btn.style.border = (n === d) ? 'none' : '1px solid var(--border)';
+ });
+ loadMovers(d);
+}
+
+function exportMoversCSV() {
+ var d = window._moversData;
+ if (!d) return;
+ var rows = [['Type','SKU','Model','Vendor','Cur Avg Price','Prior Avg Price','Delta %','Obs Count']];
+ (d.gainers||[]).forEach(function(r) {
+ rows.push(['GAINER', r.transceiver_id, r.model_name||'', r.vendor_name||'', r.cur_avg||'', r.prior_avg||'', r.delta_pct||'', r.obs_count||'']);
+ });
+ (d.losers||[]).forEach(function(r) {
+ rows.push(['LOSER', r.transceiver_id, r.model_name||'', r.vendor_name||'', r.cur_avg||'', r.prior_avg||'', r.delta_pct||'', r.obs_count||'']);
+ });
+ var csv = rows.map(function(r) { return r.map(function(c) { return '"'+String(c).replace(/"/g,'""')+'"'; }).join(','); }).join('\n');
+ var blob = new Blob([csv], { type:'text/csv' });
+ var a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = 'price-movers-' + (window._moversDays||7) + 'd.csv';
+ a.click();
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// B – PROCUREMENT PULSE (overview cards)
+// ═══════════════════════════════════════════════════════════════════════════
+async function loadProcurementPulse() {
+ var pulse = el('ov-proc-pulse');
+ var moversCard = el('ov-movers-card');
+ if (!pulse) return;
+
+ try {
+ // Fire all in parallel
+ var [reorder, arb, squeeze, movers] = await Promise.all([
+ api('/api/procurement/reorder?limit=1').catch(function() { return null; }),
+ api('/api/procurement/arbitrage?limit=1').catch(function() { return null; }),
+ api('/api/procurement/supply-squeeze?limit=1').catch(function() { return null; }),
+ api('/api/procurement/price-movers?days=7&limit=10').catch(function() { return null; }),
+ api('/api/equivalences?limit=1').catch(function() { return null; }),
+ ]);
+
+ var buySignals = reorder ? (reorder.total || (reorder.reorder_signals || []).length || 0) : 0;
+ var arbOps = arb ? (arb.total || (arb.opportunities || []).length || 0) : 0;
+ var supplyAlert = squeeze ? (squeeze.total || (squeeze.items || []).length || 0) : 0;
+ var gainers = movers ? ((movers.gainers || []).length) : 0;
+ var losers = movers ? ((movers.losers || []).length) : 0;
+
+ var cards = [
+ { icon: '🛒', label: 'Buy Signals', val: buySignals, color: '#22c55e', tab: 'procurement' },
+ { icon: '⚖️', label: 'Arbitrage Ops', val: arbOps, color: '#6366f1', tab: 'procurement' },
+ { icon: '⚠️', label: 'Supply Alerts', val: supplyAlert, color: '#f97316', tab: 'procurement' },
+ { icon: '📈', label: 'Price Gainers', val: gainers, color: '#10b981', tab: 'procurement' },
+ { icon: '📉', label: 'Price Losers', val: losers, color: '#ef4444', tab: 'procurement' },
+ ];
+
+ buildDOM(pulse, cards.map(function(c) {
+ return ''
+ + '
' + c.icon + '
'
+ + '
' + c.val + '
'
+ + '
' + c.label + '
'
+ + '
';
+ }).join(''));
+ pulse.style.display = 'grid';
+
+ // Top movers mini-table in overview
+ if (moversCard && movers && ((movers.gainers||[]).length + (movers.losers||[]).length) > 0) {
+ var inner = el('ov-movers-inner');
+ if (inner) {
+ function miniMoversTable(rows, title, color) {
+ if (!rows || !rows.length) return '';
+ return ''
+ + '
' + title + '
'
+ + '
'
+ + rows.slice(0,5).map(function(r) {
+ var pct = parseFloat(r.delta_pct);
+ var sign = pct > 0 ? '+' : '';
+ return '| ' + esc(r.model_name || String(r.transceiver_id)) + ' | '
+ + '' + sign + pct.toFixed(1) + '% |
';
+ }).join('')
+ + '
';
+ }
+ buildDOM(inner,
+ miniMoversTable(movers.gainers, 'Top Gainers (7d)', '#10b981')
+ + miniMoversTable(movers.losers, 'Top Losers (7d)', '#ef4444')
+ );
+ moversCard.style.display = '';
+ }
+ }
+ } catch(e) {
+ // Silently fail — pulse cards are bonus info
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// D – VENDOR INTELLIGENCE
+// ═══════════════════════════════════════════════════════════════════════════
+async function loadVendorIntelligence() {
+ var bar = el('vendor-intel-bar');
+ var inner = el('vendor-intel-inner');
+ if (!bar || !inner) return;
+ try {
+ var d = await api('/api/vendors/intelligence');
+ var items = d.vendors || d;
+ if (!items || !items.length) return;
+ // Show top 6 by sku_count
+ var top = items.slice(0, 6);
+ var html = top.map(function(v) {
+ return ''
+ + '
' + esc(v.vendor_name || '—') + '
'
+ + '
'
+ + '' + (v.sku_count||0) + ' SKUs '
+ + '' + (v.price_obs||0) + ' obs'
+ + '
'
+ + (v.avg_price ? '
avg $' + parseFloat(v.avg_price).toFixed(2) + '
' : '')
+ + '
';
+ }).join('');
+ buildDOM(inner, html);
+ bar.style.display = '';
+ } catch(e) {
+ // No intel available — keep bar hidden
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// F – KNOWLEDGE BASE BROWSER
+// ═══════════════════════════════════════════════════════════════════════════
+window._kbLoaded = false;
+var _kbDebounceTimer = null;
+
+function debounceKB() {
+ clearTimeout(_kbDebounceTimer);
+ _kbDebounceTimer = setTimeout(searchKB, 280);
+}
+
+async function loadKB() {
+ window._kbLoaded = true;
+ await searchKB();
+}
+
+async function searchKB() {
+ var q = (el('kb-q') ? el('kb-q').value.trim() : '');
+ var cat = (el('kb-cat') ? el('kb-cat').value : '');
+ var res = el('kb-results');
+ var pills = el('kb-cat-pills');
+ if (res) res.innerHTML = 'Loading knowledge base…
';
+ try {
+ var url = '/api/kb?limit=80';
+ if (q) url += '&q=' + encodeURIComponent(q);
+ if (cat) url += '&category=' + encodeURIComponent(cat);
+ var d = await api(url);
+ renderKB(d.entries || [], d.categories || [], d.total || 0, pills, res);
+ } catch(e) {
+ if (res) res.innerHTML = 'Failed to load knowledge base entries.
';
+ }
+}
+
+function renderKB(entries, cats, total, pillsEl, resultsEl) {
+ // ── Category pills ──────────────────────────────────────────────
+ if (pillsEl) {
+ var activeCat = el('kb-cat') ? el('kb-cat').value : '';
+ var pillHtml = 'All';
+ cats.forEach(function(c) {
+ var active = activeCat === c.category;
+ pillHtml += ''
+ + esc(c.category) + ' (' + c.count + ')'
+ + '';
+ });
+ buildDOM(pillsEl, pillHtml);
+ }
+
+ // ── Results ──────────────────────────────────────────────────────
+ if (!resultsEl) return;
+ if (!entries.length) {
+ resultsEl.innerHTML = 'No entries found.
';
+ return;
+ }
+
+ var catColors = {
+ faq: '#6366f1',
+ troubleshooting: '#f97316',
+ known_issue: '#ef4444',
+ best_practice: '#10b981',
+ glossary: '#3b82f6',
+ reference: '#a855f7',
+ };
+ var sevColors = { low:'#22c55e', medium:'#f59e0b', high:'#f97316', critical:'#ef4444' };
+
+ var html = entries.map(function(e) {
+ var catColor = catColors[e.category] || 'var(--accent)';
+ var sevColor = sevColors[e.severity] || 'var(--text-dim)';
+ var tags = (e.tags || []).slice(0,4).map(function(t) {
+ return '' + esc(t) + '';
+ }).join('');
+ var formFactors = (e.applies_to_form_factors||[]).length
+ ? 'FF: ' + esc((e.applies_to_form_factors||[]).join(', ')) + ''
+ : '';
+ var speeds = (e.applies_to_speeds||[]).length
+ ? 'Speed: ' + esc((e.applies_to_speeds||[]).join(', ')) + ''
+ : '';
+
+ return ''
+ + '
'
+ + '
' + esc(e.question || '—') + '
'
+ + '
'
+ + '' + esc(e.category) + ''
+ + (e.severity ? '' + esc(e.severity) + '' : '')
+ + '
'
+ + (e.subcategory ? '
' + esc(e.subcategory) + '
' : '')
+ + '
' + esc(e.answer || '—') + '
'
+ + ((tags || formFactors || speeds) ? '
' + tags + formFactors + speeds + '
' : '')
+ + '
';
+ }).join('');
+
+ buildDOM(resultsEl, '' + total + ' entries
' + html);
+}