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
@@ -839,6 +840,49 @@
+ + + + + +
Data Research Status
@@ -1029,6 +1073,25 @@ + + +
@@ -1063,6 +1126,11 @@
+ +
Loading vendors…
@@ -1599,6 +1667,7 @@ + @@ -1657,6 +1726,28 @@
Loading…
+ + +
ℹ Reorder Signals basieren auf ABC-Klassifizierung + Preis-Observations-Frequenz. Echte Verkaufsmengendaten → Tab.
@@ -2396,6 +2487,30 @@
+ + + @@ -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 = '' + + '' + + '' + + '' + + '' + + '' + + ''; + rows.forEach(function(r) { + var pct = parseFloat(r.delta_pct); + var color = isGainer ? 'var(--green)' : 'var(--red)'; + var sign = pct > 0 ? '+' : ''; + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
SKUAvg PriceChangeVendor
' + 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 || '—') + '
'; + 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 '' + + ''; + }).join('') + + '
' + esc(r.model_name || String(r.transceiver_id)) + '' + sign + pct.toFixed(1) + '%
'; + } + 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); +}