diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9beeb34..c8cbd96 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -38,6 +38,10 @@ import { tipLlmRouter } from "./routes/tip-llm"; import { equivalencesRouter } from "./routes/equivalences"; import { priceHistoryRouter } from "./routes/price-history"; import { kbRouter } from "./routes/kb"; +import { bulkPriceRouter } from "./routes/bulk-price"; +import { vendorReliabilityRouter } from "./routes/vendor-reliability"; +import { priceForecastRouter } from "./routes/price-forecast"; +import { priceMatrixRouter } from "./routes/price-matrix"; const app = express(); @@ -110,6 +114,14 @@ app.use("/api/equivalences", equivalencesRouter); // Price history charts app.use("/api/price-history", priceHistoryRouter); app.use("/api/kb", kbRouter); +// Bulk price lookup (G) +app.use("/api/bulk-price", bulkPriceRouter); +// Vendor reliability scores (I) +app.use("/api/vendors/reliability", vendorReliabilityRouter); +// Price forecast (O) +app.use("/api/price-forecast", priceForecastRouter); +// Price matrix / heat map (J) +app.use("/api/price-matrix", priceMatrixRouter); // Dashboard (static HTML) app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard"))); diff --git a/packages/api/src/routes/bulk-price.ts b/packages/api/src/routes/bulk-price.ts new file mode 100644 index 0000000..f8d5cc3 --- /dev/null +++ b/packages/api/src/routes/bulk-price.ts @@ -0,0 +1,156 @@ +/** + * Bulk Price Lookup + * + * Routes: + * POST /api/bulk-price — Get current prices for multiple part numbers at once + */ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; + +export const bulkPriceRouter = Router(); + +const MAX_PART_NUMBERS = 100; + +// ─── POST /api/bulk-price ───────────────────────────────────────────────────── +bulkPriceRouter.post("/", async (req: Request, res: Response) => { + try { + const { part_numbers, limit } = req.body as { + part_numbers?: unknown; + limit?: unknown; + }; + + if (!Array.isArray(part_numbers) || part_numbers.length === 0) { + res.status(400).json({ success: false, error: "part_numbers must be a non-empty array" }); + return; + } + + const safe = part_numbers + .filter((p): p is string => typeof p === "string" && p.trim().length > 0) + .slice(0, MAX_PART_NUMBERS) + .map((p) => p.trim()); + + if (safe.length === 0) { + res.status(400).json({ success: false, error: "No valid part numbers provided" }); + return; + } + + const perVendorLimit = typeof limit === "number" && limit > 0 ? Math.min(limit, 50) : 10; + + // Build $1,$2,... placeholders for the IN clause + const placeholders = safe.map((_, i) => `$${i + 1}`).join(", "); + + const result = await pool.query<{ + part_number: string; + transceiver_id: number; + model_name: string; + form_factor: string; + speed_gbps: number; + vendor_id: number; + vendor_name: string; + price: string; + currency: string; + observed_at: Date; + }>( + `WITH matched AS ( + SELECT id, part_number, model_name, form_factor, speed_gbps + FROM transceivers + WHERE part_number ILIKE ANY (ARRAY[${placeholders}]) + ), + recent_prices AS ( + SELECT DISTINCT ON (po.transceiver_id, po.source_vendor_id) + po.transceiver_id, + po.source_vendor_id, + po.price, + po.currency, + po.time AS observed_at + FROM price_observations po + JOIN matched m ON m.id = po.transceiver_id + WHERE po.time > NOW() - INTERVAL '30 days' + ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC + ) + SELECT + m.part_number, + m.id AS transceiver_id, + m.model_name, + m.form_factor, + m.speed_gbps, + v.id AS vendor_id, + v.name AS vendor_name, + rp.price, + rp.currency, + rp.observed_at + FROM matched m + LEFT JOIN recent_prices rp ON rp.transceiver_id = m.id + LEFT JOIN vendors v ON v.id = rp.source_vendor_id + ORDER BY m.part_number, rp.price ASC NULLS LAST + LIMIT $${safe.length + 1}`, + [...safe, safe.length * perVendorLimit] + ); + + // Group rows by part_number + type PriceEntry = { + vendor_id: number; + vendor_name: string; + price_usd: number; // normalised name for API output + currency: string; + observed_at: string; + }; + type ResultEntry = { + part_number: string; + transceiver_id: number; + model_name: string; + form_factor: string; + speed_gbps: number; + prices: PriceEntry[]; + best_price_usd: number | null; + price_count: number; + }; + + const map = new Map(); + + for (const row of result.rows) { + if (!map.has(row.part_number)) { + map.set(row.part_number, { + part_number: row.part_number, + transceiver_id: row.transceiver_id, + model_name: row.model_name, + form_factor: row.form_factor, + speed_gbps: row.speed_gbps, + prices: [], + best_price_usd: null, + price_count: 0, + }); + } + const entry = map.get(row.part_number)!; + if (row.vendor_id !== null && row.price !== null) { + const priceNum = parseFloat(row.price); + entry.prices.push({ + vendor_id: row.vendor_id, + vendor_name: row.vendor_name, + price_usd: priceNum, + currency: row.currency, + observed_at: row.observed_at.toISOString(), + }); + if (entry.best_price_usd === null || priceNum < entry.best_price_usd) { + entry.best_price_usd = priceNum; + } + entry.price_count += 1; + } + } + + const foundKeys = new Set(map.keys()); + const notFound = safe.filter( + (pn) => !Array.from(foundKeys).some((k) => k.toLowerCase() === pn.toLowerCase()) + ); + + res.json({ + success: true, + results: Array.from(map.values()), + total_found: map.size, + not_found: notFound, + }); + } catch (err) { + console.error("POST /api/bulk-price error:", err); + res.status(500).json({ success: false, error: String(err) }); + } +}); diff --git a/packages/api/src/routes/price-forecast.ts b/packages/api/src/routes/price-forecast.ts new file mode 100644 index 0000000..9e07b50 --- /dev/null +++ b/packages/api/src/routes/price-forecast.ts @@ -0,0 +1,111 @@ +/** + * Price Forecast — Linear Regression + * + * Routes: + * GET /api/price-forecast/:id — 30-day forecast for a transceiver + */ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; + +export const priceForecastRouter = Router(); + +const MIN_PRICE = 0.01; + +function linearRegression(xs: number[], ys: number[]): { slope: number; intercept: number; rSquared: number } { + const n = xs.length; + if (n < 2) return { slope: 0, intercept: ys[0] ?? 0, rSquared: 0 }; + + const sumX = xs.reduce((a, b) => a + b, 0); + const sumY = ys.reduce((a, b) => a + b, 0); + const sumXY = xs.reduce((acc, x, i) => acc + x * ys[i], 0); + const sumX2 = xs.reduce((acc, x) => acc + x * x, 0); + + const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + + const yMean = sumY / n; + const ssTot = ys.reduce((acc, y) => acc + (y - yMean) ** 2, 0); + const ssRes = xs.reduce((acc, x, i) => acc + (ys[i] - (slope * x + intercept)) ** 2, 0); + const rSquared = ssTot === 0 ? 0 : 1 - ssRes / ssTot; + + return { slope, intercept, rSquared }; +} + +function addDays(base: Date, n: number): string { + const d = new Date(base); + d.setUTCDate(d.getUTCDate() + n); + return d.toISOString().slice(0, 10); +} + +// ─── GET /api/price-forecast/:id ───────────────────────────────────────────── +priceForecastRouter.get("/:id", async (req: Request, res: Response) => { + try { + const id = parseInt(String(req.params.id), 10); + if (!Number.isFinite(id) || id <= 0) { + res.status(400).json({ success: false, error: "Invalid transceiver id" }); + return; + } + + const histResult = await pool.query<{ day: Date; avg_price: string }>( + `SELECT + DATE(time) AS day, + AVG(price) AS avg_price + FROM price_observations + WHERE transceiver_id = $1 + AND time > NOW() - INTERVAL '90 days' + GROUP BY DATE(time) + ORDER BY day`, + [id] + ); + + if (histResult.rows.length === 0) { + res.status(404).json({ success: false, error: "No price history found for this transceiver" }); + return; + } + + const history = histResult.rows.map((r) => ({ + date: r.day.toISOString().slice(0, 10), + avg_price: parseFloat(r.avg_price), + })); + + // Use day-0 offset as x axis so numbers stay small + const epoch0 = new Date(history[0].date + "T00:00:00Z").getTime(); + const xs = history.map((h) => (new Date(h.date + "T00:00:00Z").getTime() - epoch0) / 86_400_000); + const ys = history.map((h) => h.avg_price); + + const { slope, intercept, rSquared } = linearRegression(xs, ys); + + const lastDate = new Date(history[history.length - 1].date + "T00:00:00Z"); + const lastX = xs[xs.length - 1]; + const forecast = Array.from({ length: 30 }, (_, i) => { + const dayOffset = lastX + i + 1; + const rawPrice = slope * dayOffset + intercept; + const predictedPrice = Math.max(MIN_PRICE, Math.round(rawPrice * 100) / 100); + return { + date: addDays(lastDate, i + 1), + predicted_price: predictedPrice, + is_forecast: true, + }; + }); + + const trend = + slope > 0.05 ? "rising" : + slope < -0.05 ? "declining" : "stable"; + + const forecast30dPrice = forecast[29].predicted_price; + + res.json({ + success: true, + transceiver_id: id, + history, + forecast, + trend, + slope_per_day: Math.round(slope * 10_000) / 10_000, + r_squared: Math.round(rSquared * 100) / 100, + forecast_30d_price: forecast30dPrice, + }); + } catch (err) { + console.error("GET /api/price-forecast/:id error:", err); + res.status(500).json({ success: false, error: String(err) }); + } +}); diff --git a/packages/api/src/routes/price-matrix.ts b/packages/api/src/routes/price-matrix.ts new file mode 100644 index 0000000..ff23183 --- /dev/null +++ b/packages/api/src/routes/price-matrix.ts @@ -0,0 +1,136 @@ +/** + * Price Matrix — Transceiver × Vendor Grid + * + * Routes: + * GET /api/price-matrix — Price matrix for selected (or top) transceivers + * Query params: + * ids — comma-separated transceiver IDs (max 20); omit for auto top-10 + * limit — how many top transceivers to return when ids is omitted (default 10) + */ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; + +export const priceMatrixRouter = Router(); + +const MAX_IDS = 20; +const MAX_LIMIT = 50; + +// ─── GET /api/price-matrix ──────────────────────────────────────────────────── +priceMatrixRouter.get("/", async (req: Request, res: Response) => { + try { + let transceiverIds: number[]; + + if (typeof req.query.ids === "string" && req.query.ids.trim().length > 0) { + const parsed = req.query.ids + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n) && n > 0) + .slice(0, MAX_IDS); + + if (parsed.length === 0) { + res.status(400).json({ success: false, error: "No valid transceiver IDs provided" }); + return; + } + transceiverIds = parsed; + } else { + // Auto-select top N by observation count in last 30 days + const rawLimit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 10; + const topLimit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, MAX_LIMIT) : 10; + + const topResult = await pool.query<{ transceiver_id: number }>( + `SELECT transceiver_id + FROM price_observations + WHERE time > NOW() - INTERVAL '30 days' + GROUP BY transceiver_id + ORDER BY COUNT(*) DESC + LIMIT $1`, + [topLimit] + ); + + transceiverIds = topResult.rows.map((r) => r.transceiver_id); + + if (transceiverIds.length === 0) { + res.json({ success: true, transceivers: [], vendors: [], matrix: {}, best_prices: {} }); + return; + } + } + + const placeholders = transceiverIds.map((_, i) => `$${i + 1}`).join(", "); + + const [txResult, priceResult] = await Promise.all([ + pool.query<{ + id: number; + model_name: string; + part_number: string; + form_factor: string; + speed_gbps: number; + }>( + `SELECT id, model_name, part_number, form_factor, speed_gbps + FROM transceivers + WHERE id IN (${placeholders}) + ORDER BY id`, + transceiverIds + ), + + pool.query<{ + transceiver_id: number; + vendor_id: number; + vendor_name: string; + price: string; + }>( + `SELECT DISTINCT ON (po.transceiver_id, po.source_vendor_id) + po.transceiver_id, + po.source_vendor_id AS vendor_id, + v.name AS vendor_name, + po.price + FROM price_observations po + JOIN vendors v ON v.id = po.source_vendor_id + WHERE po.transceiver_id IN (${placeholders}) + ORDER BY po.transceiver_id, po.source_vendor_id, po.time DESC`, + transceiverIds + ), + ]); + + // Build vendor list (deduplicated, stable order) + const vendorMap = new Map(); + for (const row of priceResult.rows) { + if (!vendorMap.has(row.vendor_id)) { + vendorMap.set(row.vendor_id, row.vendor_name); + } + } + const vendors = Array.from(vendorMap.entries()).map(([vendor_id, vendor_name]) => ({ + vendor_id, + vendor_name, + })); + + // Build matrix and best_prices + const matrix: Record> = {}; + const bestPrices: Record = {}; + + for (const row of priceResult.rows) { + const txKey = String(row.transceiver_id); + const vKey = String(row.vendor_id); + const price = parseFloat(row.price); + + if (!Number.isFinite(price)) continue; + + if (!matrix[txKey]) matrix[txKey] = {}; + matrix[txKey][vKey] = price; + + if (bestPrices[txKey] === undefined || price < bestPrices[txKey]) { + bestPrices[txKey] = price; + } + } + + res.json({ + success: true, + transceivers: txResult.rows, + vendors, + matrix, + best_prices: bestPrices, + }); + } catch (err) { + console.error("GET /api/price-matrix error:", err); + res.status(500).json({ success: false, error: String(err) }); + } +}); diff --git a/packages/api/src/routes/vendor-reliability.ts b/packages/api/src/routes/vendor-reliability.ts new file mode 100644 index 0000000..889ade0 --- /dev/null +++ b/packages/api/src/routes/vendor-reliability.ts @@ -0,0 +1,83 @@ +/** + * Vendor Reliability Scores + * + * Routes: + * GET /api/vendor-reliability — Reliability score 0–100 per vendor + */ +import { Router, Request, Response } from "express"; +import { pool } from "../db/client"; + +export const vendorReliabilityRouter = Router(); + +// ─── GET /api/vendor-reliability ───────────────────────────────────────────── +vendorReliabilityRouter.get("/", async (_req: Request, res: Response) => { + try { + const result = await pool.query<{ + vendor_id: number; + vendor_name: string; + last_observation: Date; + obs_30d: string; + distinct_skus_60d: string; + days_since_last: string; + }>(` + WITH base AS ( + SELECT + po.source_vendor_id AS vendor_id, + MAX(po.time) AS last_observation, + COUNT(*) FILTER (WHERE po.time > NOW() - INTERVAL '30 days') + AS obs_30d, + COUNT(DISTINCT po.transceiver_id) + FILTER (WHERE po.time > NOW() - INTERVAL '60 days') + AS distinct_skus_60d + FROM price_observations po + WHERE po.time > NOW() - INTERVAL '90 days' + GROUP BY po.source_vendor_id + ) + SELECT + b.vendor_id, + v.name AS vendor_name, + b.last_observation, + b.obs_30d, + b.distinct_skus_60d, + EXTRACT(EPOCH FROM (NOW() - b.last_observation)) / 86400.0 AS days_since_last + FROM base b + JOIN vendors v ON v.id = b.vendor_id + ORDER BY b.last_observation DESC + `); + + const vendors = result.rows.map((row) => { + const days = parseFloat(row.days_since_last); + const obs30d = parseInt(row.obs_30d, 10); + const skus60d = parseInt(row.distinct_skus_60d, 10); + + const freshnessScore = + days <= 7 ? 40 : + days <= 14 ? 30 : + days <= 30 ? 20 : + days <= 60 ? 10 : 0; + + const frequencyScore = Math.min(Math.round((obs30d / 100) * 30), 30); + const coverageScore = Math.min(Math.round((skus60d / 500) * 30), 30); + const reliabilityScore = freshnessScore + frequencyScore + coverageScore; + + return { + vendor_id: row.vendor_id, + vendor_name: row.vendor_name, + reliability_score: reliabilityScore, + freshness_score: freshnessScore, + frequency_score: frequencyScore, + coverage_score: coverageScore, + last_observation: row.last_observation.toISOString().slice(0, 10), + obs_30d: obs30d, + distinct_skus_60d: skus60d, + }; + }); + + vendors.sort((a, b) => b.reliability_score - a.reliability_score); + + res.json({ success: true, vendors }); + } catch (err) { + console.error("GET /api/vendor-reliability error:", err); + res.status(500).json({ success: false, error: String(err) }); + } +}); diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index dd3f3d3..3c6f8af 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -807,11 +807,16 @@
💲 Price Comparison
🔀 Equivalences
📚 KB
+
🧾 Bulk
+
+ + +
@@ -1047,10 +1052,26 @@
Loading marketplace data…
+ + +
+
+
+
📡 Technology Radar
+
Hype Cycle phases mapped to Adopt / Trial / Assess / Hold rings
+
+ +
+
+
Click "Render Radar" to generate the interactive technology radar.
+
+
+ + + + + @@ -2511,6 +2551,77 @@ + + + + + + + + + + + + + +
+ + + + @@ -2883,6 +2994,7 @@ function goToTab(tabName) { if (tabName === 'news') loadNews(1); if (tabName === 'vendors') loadVendors(); if (tabName === 'kb' && !window._kbLoaded) loadKB(); + if (tabName === 'bulk') initBulkTab(); if (tabName === 'standards') loadStandardsList(); if (tabName === 'blog') { loadBlogDrafts(); loadSLLInsights(); loadBlogLLMStatus(); loadPostingTime(); } if (tabName === 'finder') document.getElementById('finder-switch-input').focus(); @@ -3891,6 +4003,7 @@ function searchTransceivers() { el('tx-table').querySelectorAll('tr.clickable').forEach(function(row) { row.addEventListener('click', function() { openTxDetail(this.getAttribute('data-txid')); }); }); + if (typeof window.addStarToTxRows === 'function') window.addStarToTxRows(); }); } @@ -4356,7 +4469,24 @@ async function openTxDetail(id) { 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); }) + .then(function(d) { + window._ph.data = d; phDraw(d); + // Overlay dashed forecast line after chart renders + setTimeout(function() { + var svgEl = document.querySelector('#ph-inner svg'); + if (svgEl && typeof overlayForecast === 'function') { + var series = d.series || []; + var dayStrs = Array.from(new Set(series.map(function(r){ return (r.day||'').substring(0,10); }))).sort(); + if (dayStrs.length >= 3) { + var minDay = new Date(dayStrs[0]).getTime(); + var maxDay = new Date(dayStrs[dayStrs.length-1]).getTime(); + var vals = series.filter(function(r){ return +r.price_avg > 0; }).map(function(r){ return +r.price_avg; }); + var minY = Math.min.apply(null, vals); var maxY = Math.max.apply(null, vals); + overlayForecast(txId, svgEl, W, PL, PR, PT, PB, H, minY, maxY, minDay, maxDay); + } + } + }, 120); + }) .catch(function() { var i = document.getElementById('ph-inner'); if (i) i.textContent = 'Price history unavailable.'; }); } @@ -5202,6 +5332,7 @@ async function loadVendors() { }); filterVendorCards(); loadVendorIntelligence(); + loadVendorReliability(); } function filterVendorCards() { @@ -7176,7 +7307,7 @@ var procAiClustersMinTx = 0; function showProcSection(name) { ['signals','reorder-top','arbitrage','switch-compat','supply-squeeze','dead-stock','movers', - 'abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) { + 'abc','demand','marketplace','ai-clusters','market','lifecycle','heatmap'].forEach(function(s) { var sec = el('proc-section-' + s); var btn = el('proc-btn-' + s); if (sec) sec.style.display = s === name ? '' : 'none'; @@ -7192,6 +7323,7 @@ function showProcSection(name) { 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); + if (name === 'heatmap' && !window._heatmapLoaded) loadHeatMap(); } /* ── E: Buy-Now Reorder Intelligence ───────────────────────────────────── */ @@ -9862,6 +9994,701 @@ function renderKB(entries, cats, total, pillsEl, resultsEl) { buildDOM(resultsEl, '
' + total + ' entries
' + html); } + +// ═══════════════════════════════════════════════════════════════════════════ +// G – BULK PRICER +// ═══════════════════════════════════════════════════════════════════════════ +window._bulkResults = null; + +function initBulkTab() { /* no-op placeholder for tab init */ } + +async function runBulkPrice() { + var raw = el('bulk-input') ? el('bulk-input').value : ''; + if (!raw.trim()) return; + var parts = raw.split(/[\n,]+/).map(function(s) { return s.trim(); }).filter(Boolean).slice(0, 100); + var status = el('bulk-status'); + var res = el('bulk-results'); + var exportBtn = el('bulk-export-btn'); + if (status) status.textContent = 'Fetching prices for ' + parts.length + ' SKUs…'; + if (res) res.innerHTML = '
Querying market data…
'; + if (exportBtn) exportBtn.style.display = 'none'; + try { + var tok = localStorage.getItem('tip_token') || ''; + var resp = await fetch('/api/bulk-price', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tok }, + body: JSON.stringify({ part_numbers: parts }) + }); + var d = await resp.json(); + window._bulkResults = d; + if (status) status.textContent = d.total_found + ' of ' + parts.length + ' found' + (d.not_found && d.not_found.length ? ' · ' + d.not_found.length + ' not found' : ''); + var html = ''; + if (d.not_found && d.not_found.length) { + html += '
' + + 'Not found: ' + d.not_found.map(esc).join(', ') + '
'; + } + (d.results || []).forEach(function(r) { + html += '
' + + '
' + + '
' + + '
' + esc(r.part_number) + '
' + + (r.model_name ? '
' + esc(r.model_name) + '
' : '') + + '
' + + '
' + + (r.form_factor ? '' + esc(r.form_factor) + '' : '') + + (r.speed_gbps ? '' + r.speed_gbps + 'G' : '') + + '
' + + '
'; + if (!r.prices || !r.prices.length) { + html += '
No current pricing data available.
'; + } else { + html += '' + + ''; + var best = parseFloat(r.best_price_usd || 0); + r.prices.forEach(function(p) { + var isBest = parseFloat(p.price || p.price_usd || 0) === best; + html += '' + + '' + + '' + + '' + + '' + + ''; + }); + html += '
VendorPriceCurrencyObserved
' + esc(p.vendor_name || '—') + (isBest ? ' ★' : '') + '$' + parseFloat(p.price || p.price_usd || 0).toFixed(2) + '' + esc(p.currency || 'USD') + '' + (p.observed_at ? new Date(p.observed_at).toLocaleDateString() : '—') + '
'; + } + html += '
'; + }); + if (res) buildDOM(res, html || '
No results.
'); + if (exportBtn && d.results && d.results.length) exportBtn.style.display = ''; + } catch(e) { + if (status) status.textContent = 'Error: ' + String(e); + if (res) res.innerHTML = '
Request failed.
'; + } +} + +function exportBulkCSV() { + var d = window._bulkResults; + if (!d || !d.results) return; + var rows = [['Part Number','Model','Form Factor','Speed (G)','Vendor','Price USD','Currency','Observed At']]; + d.results.forEach(function(r) { + (r.prices || []).forEach(function(p) { + rows.push([r.part_number, r.model_name||'', r.form_factor||'', r.speed_gbps||'', p.vendor_name||'', p.price||p.price_usd||'', p.currency||'USD', p.observed_at||'']); + }); + if (!r.prices || !r.prices.length) rows.push([r.part_number, r.model_name||'', r.form_factor||'', r.speed_gbps||'', 'N/A','','','']); + }); + var csv = rows.map(function(r) { return r.map(function(c) { return '"'+String(c).replace(/"/g,'""')+'"'; }).join(','); }).join('\n'); + var a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv],{type:'text/csv'})); + a.download = 'bulk-prices.csv'; a.click(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// H – SIDE-BY-SIDE COMPARISON +// ═══════════════════════════════════════════════════════════════════════════ +window._compareSet = new Set(); + +document.addEventListener('change', function(e) { + if (!e.target.classList.contains('compare-cb')) return; + var id = e.target.getAttribute('data-id'); + if (!id) return; + if (e.target.checked) { + if (window._compareSet.size >= 4) { e.target.checked = false; alert('Max 4 SKUs for comparison.'); return; } + window._compareSet.add(id); + } else { + window._compareSet.delete(id); + } + updateCompareTray(); +}); + +function updateCompareTray() { + var tray = el('compare-tray'); + var slots = el('compare-slots'); + if (!tray) return; + if (!window._compareSet.size) { tray.style.display = 'none'; return; } + tray.style.display = ''; + var ids = Array.from(window._compareSet); + var labels = ids.map(function(id) { + var row = document.querySelector('tr[data-txid="' + id + '"]'); + var name = row ? (row.cells[1] ? row.cells[1].textContent.trim().slice(0,20) : id) : id; + return '' + + esc(name) + ''; + }); + buildDOM(slots, labels.join('')); +} + +function removeFromCompare(id) { + window._compareSet.delete(id); + var cb = document.querySelector('.compare-cb[data-id="' + id + '"]'); + if (cb) cb.checked = false; + updateCompareTray(); +} + +function clearCompare() { + window._compareSet.clear(); + document.querySelectorAll('.compare-cb').forEach(function(cb) { cb.checked = false; }); + updateCompareTray(); +} + +async function openComparison() { + var ids = Array.from(window._compareSet); + if (ids.length < 2) { alert('Select at least 2 transceivers to compare.'); return; } + var modal = el('compare-modal'); + var body = el('compare-body'); + if (!modal || !body) return; + modal.style.display = ''; + body.innerHTML = '
Loading comparison data…
'; + try { + var items = await Promise.all(ids.map(function(id) { + return api('/api/transceivers/' + id).catch(function() { return null; }); + })); + var prices = await Promise.all(ids.map(function(id) { + return api('/api/price-history/' + id + '?days=7').catch(function() { return null; }); + })); + var fields = [ + { label: 'Part Number', key: 'part_number' }, + { label: 'Model Name', key: 'model_name' }, + { label: 'Vendor', key: 'vendor_name' }, + { label: 'Form Factor', key: 'form_factor' }, + { label: 'Speed', key: 'speed' }, + { label: 'Reach', key: 'reach_label' }, + { label: 'Fiber Type', key: 'fiber_type' }, + { label: 'Connector', key: 'connector_type' }, + { label: 'Temp. Range', key: 'temperature_range' }, + { label: 'Price Tier', key: 'price_tier' }, + { label: 'Street Price',fn: function(t) { return t.street_price_usd ? '$' + parseFloat(t.street_price_usd).toFixed(2) : '—'; } }, + { label: 'Market Status',key:'market_status' }, + ]; + var html = '' + + '' + + items.map(function(t,i) { + return ''; + }).join('') + + ''; + fields.forEach(function(f, fi) { + html += '' + + '' + + items.map(function(t) { + var v = t ? (f.fn ? f.fn(t) : (t[f.key] || '—')) : '—'; + return ''; + }).join('') + + ''; + }); + // Best current price row + html += '' + + '' + + prices.map(function(p) { + var best = p && p.best_prices && p.best_prices.length ? ('$' + parseFloat(p.best_prices[0].best_price).toFixed(2) + ' · ' + esc(p.best_prices[0].vendor || '')) : '—'; + return ''; + }).join('') + + ''; + html += '
Attribute' + + esc(t && (t.part_number || t.model_name) ? (t.part_number || t.model_name) : 'SKU ' + (i+1)) + '
' + f.label + '' + esc(String(v)) + '
Best Price (7d)' + best + '
'; + buildDOM(body, html); + } catch(e) { + body.innerHTML = '
Failed to load comparison data.
'; + } +} + +function closeComparison() { + var m = el('compare-modal'); + if (m) m.style.display = 'none'; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// I – VENDOR RELIABILITY (scores merged into vendor cards) +// ═══════════════════════════════════════════════════════════════════════════ +window._vendorReliability = {}; + +async function loadVendorReliability() { + try { + var d = await api('/api/vendors/reliability'); + (d.vendors || []).forEach(function(v) { + window._vendorReliability[v.vendor_id] = v; + }); + // Apply to already-rendered cards + document.querySelectorAll('[data-vendor-id]').forEach(applyReliabilityToCard); + } catch(e) { /* optional feature */ } +} + +function applyReliabilityToCard(card) { + var vid = card.getAttribute('data-vendor-id'); + if (!vid || window._vendorReliability[vid] === undefined) return; + if (card.querySelector('.rel-score-badge')) return; // already applied + var r = window._vendorReliability[vid]; + var score = r.reliability_score || 0; + var color = score >= 70 ? '#22c55e' : score >= 40 ? '#f59e0b' : '#ef4444'; + var badge = document.createElement('div'); + badge.className = 'rel-score-badge'; + badge.setAttribute('title', 'Reliability Score: ' + score + '/100 (freshness ' + r.freshness_score + ', freq ' + r.frequency_score + ', coverage ' + r.coverage_score + ')'); + badge.style.cssText = 'margin-top:0.4rem;display:flex;align-items:center;gap:0.4rem;font-size:0.68rem'; + badge.innerHTML = '
' + + '' + score + '/100'; + card.appendChild(badge); +} + +// Hook into filterVendorCards — apply reliability after render +var _origFilterVendorCards = typeof filterVendorCards === 'function' ? filterVendorCards : null; + +// ═══════════════════════════════════════════════════════════════════════════ +// J – PRICE HEAT MAP +// ═══════════════════════════════════════════════════════════════════════════ +window._heatmapLoaded = false; + +async function loadHeatMap() { + window._heatmapLoaded = true; + var container = el('heatmap-container'); + if (!container) return; + container.innerHTML = '
Loading price matrix…
'; + var ids = ''; + var input = el('heatmap-ids'); + if (input && input.value.trim()) { + ids = '?ids=' + encodeURIComponent(input.value.trim()); + } + try { + var d = await api('/api/price-matrix' + ids); + renderHeatMap(d, container); + } catch(e) { + container.innerHTML = '
Failed to load price matrix: ' + esc(String(e)) + '
'; + } +} + +function renderHeatMap(d, container) { + if (!d || !d.transceivers || !d.vendors || !d.matrix) { + container.innerHTML = '
No matrix data available.
'; + return; + } + var txs = d.transceivers; + var vendors = d.vendors; + var matrix = d.matrix; + var bestPrices = d.best_prices || {}; + + // Collect all prices for color scale + var allPrices = []; + txs.forEach(function(t) { + vendors.forEach(function(v) { + var p = matrix[t.id] && matrix[t.id][v.vendor_id]; + if (p) allPrices.push(parseFloat(p)); + }); + }); + var minP = Math.min.apply(null, allPrices); + var maxP = Math.max.apply(null, allPrices); + var range = maxP - minP || 1; + + function cellColor(price) { + if (!price) return 'var(--surface3)'; + var t = (parseFloat(price) - minP) / range; // 0=cheapest, 1=most expensive + // green → yellow → red + if (t < 0.5) { + var g = Math.round(160 + (1 - t * 2) * 60); + return 'rgba(34,' + g + ',80,' + (0.15 + t * 0.3) + ')'; + } else { + var r = Math.round(180 + t * 60); + return 'rgba(' + r + ',80,50,' + (0.15 + t * 0.3) + ')'; + } + } + + var html = '
Color: 🟢 cheapest → 🔴 most expensive per row. Gray = no data.
'; + html += '' + + '' + + '' + + vendors.map(function(v) { + return ''; + }).join('') + + '' + + ''; + + txs.forEach(function(t, ri) { + // Per-row color scale (row min/max) + var rowPrices = vendors.map(function(v) { + var p = matrix[t.id] && matrix[t.id][v.vendor_id]; + return p ? parseFloat(p) : null; + }).filter(function(p) { return p !== null; }); + var rMin = rowPrices.length ? Math.min.apply(null, rowPrices) : 0; + var rMax = rowPrices.length ? Math.max.apply(null, rowPrices) : 1; + var rRange = rMax - rMin || 1; + + html += '' + + ''; + vendors.forEach(function(v) { + var raw = matrix[t.id] && matrix[t.id][v.vendor_id]; + if (!raw) { + html += ''; + } else { + var p = parseFloat(raw); + var t2 = (p - rMin) / rRange; + var bg = t2 < 0.33 ? 'rgba(34,197,94,0.2)' : t2 < 0.66 ? 'rgba(245,158,11,0.2)' : 'rgba(239,68,68,0.2)'; + var fc = t2 < 0.33 ? '#16a34a' : t2 < 0.66 ? '#d97706' : '#dc2626'; + html += ''; + } + }); + var best = bestPrices[t.id]; + html += ''; + html += ''; + }); + + html += '
SKU' + esc((v.vendor_name || '').substring(0, 12)) + 'Best
' + esc(t.model_name || t.part_number || String(t.id)) + '$' + p.toFixed(2) + '' + (best ? '$' + parseFloat(best).toFixed(2) : '—') + '
'; + html += '
' + txs.length + ' SKUs × ' + vendors.length + ' vendors
'; + buildDOM(container, html); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// K – WATCHLIST +// ═══════════════════════════════════════════════════════════════════════════ +(function() { + try { window._watchlist = new Set(JSON.parse(localStorage.getItem('tip_watchlist') || '[]')); } + catch(e) { window._watchlist = new Set(); } +})(); + +function saveWatchlist() { + try { localStorage.setItem('tip_watchlist', JSON.stringify(Array.from(window._watchlist))); } catch(e) {} + updateWatchlistBadge(); +} + +function updateWatchlistBadge() { + var badge = el('watchlist-count-badge'); + if (!badge) return; + var count = window._watchlist.size; + badge.textContent = count; + badge.style.display = count ? '' : 'none'; +} + +function toggleWatchlistItem(id, name) { + if (window._watchlist.has(id)) { window._watchlist.delete(id); } else { window._watchlist.add(id); } + saveWatchlist(); + renderWatchlist(); +} + +function toggleWatchlist() { + var d = el('watchlist-drawer'); + if (!d) return; + d.style.display = d.style.display === 'none' ? '' : 'none'; + if (d.style.display !== 'none') renderWatchlist(); +} + +function renderWatchlist() { + var container = el('watchlist-items'); + if (!container) return; + if (!window._watchlist.size) { + container.innerHTML = '
No items. Click ⭐ on any transceiver row to add it.
'; + return; + } + container.innerHTML = '
Loading…
'; + Promise.all(Array.from(window._watchlist).map(function(id) { + return api('/api/transceivers/' + id).catch(function() { return { id: id, part_number: id }; }); + })).then(function(items) { + var html = items.map(function(t) { + return '
' + + '
' + + '
' + esc(t.part_number || t.model_name || String(t.id)) + '
' + + (t.form_factor ? '
' + esc(t.form_factor) + (t.speed_gbps ? ' · ' + t.speed_gbps + 'G' : '') + '
' : '') + + (t.street_price_usd ? '
$' + parseFloat(t.street_price_usd).toFixed(2) + '
' : '') + + '
' + + '' + + '
'; + }).join(''); + buildDOM(container, html); + }); +} + +// Expose for use in tx table row rendering +window.addStarToTxRows = function() { + el('tx-table') && el('tx-table').querySelectorAll('tr[data-txid]').forEach(function(row) { + var id = row.getAttribute('data-txid'); + if (!id || row.querySelector('.wl-star')) return; + var starTd = document.createElement('td'); + starTd.innerHTML = '' + (window._watchlist.has(id) ? '★' : '☆') + ''; + row.appendChild(starTd); + }); +}; +updateWatchlistBadge(); + +// ═══════════════════════════════════════════════════════════════════════════ +// L – PDF / PRINT REPORT +// ═══════════════════════════════════════════════════════════════════════════ +function exportPDF() { + window.print(); +} + +// Inject print CSS once +(function() { + var s = document.createElement('style'); + s.textContent = '@media print { #compare-tray,#watchlist-btn,#watchlist-count-badge,#watchlist-drawer,#global-search-overlay,#compare-modal,.tab-nav,.app-header,.proc-btn,.btn-sm,[data-tab]{display:none!important} #tab-overview{display:block!important} body{background:#fff;color:#000} .card{border:1px solid #ccc!important;break-inside:avoid} }'; + document.head.appendChild(s); +})(); + +// ═══════════════════════════════════════════════════════════════════════════ +// M – GLOBAL SEARCH OVERLAY +// ═══════════════════════════════════════════════════════════════════════════ +var _gsDebounceTimer = null; + +function openGlobalSearch() { + var overlay = el('global-search-overlay'); + if (!overlay) return; + overlay.style.display = ''; + setTimeout(function() { var inp = el('gs-input'); if (inp) inp.focus(); }, 50); +} + +function closeGlobalSearch() { + var overlay = el('global-search-overlay'); + if (overlay) overlay.style.display = 'none'; +} + +function debounceGS() { + clearTimeout(_gsDebounceTimer); + _gsDebounceTimer = setTimeout(runGlobalSearch, 300); +} + +async function runGlobalSearch() { + var q = el('gs-input') ? el('gs-input').value.trim() : ''; + var res = el('gs-results'); + if (!res) return; + if (q.length < 2) { + res.innerHTML = '
Type at least 2 characters to search…
'; + return; + } + res.innerHTML = '
Searching…
'; + var useTx = el('gs-tx') ? el('gs-tx').checked : true; + var useKb = el('gs-kb') ? el('gs-kb').checked : true; + var useNews = el('gs-news') ? el('gs-news').checked : true; + var useDocs = el('gs-docs') ? el('gs-docs').checked : false; + + var tasks = []; + if (useTx) tasks.push(api('/api/transceivers?q=' + encodeURIComponent(q) + '&limit=5').catch(function(){return null;})); + if (useKb) tasks.push(api('/api/kb?q=' + encodeURIComponent(q) + '&limit=5').catch(function(){return null;})); + if (useNews) tasks.push(api('/api/search?q=' + encodeURIComponent(q) + '&collection=news_embeddings&limit=5').catch(function(){return null;})); + if (useDocs) tasks.push(api('/api/search?q=' + encodeURIComponent(q) + '&collection=document_embeddings&limit=5').catch(function(){return null;})); + var [txData, kbData, newsData, docsData] = await Promise.all(tasks); + + var html = ''; + + if (useTx && txData && txData.data && txData.data.length) { + html += '
Products
'; + txData.data.slice(0,5).forEach(function(t) { + html += '
' + + '🔌' + + '
' + esc(t.part_number || t.model_name) + '
' + + '
' + esc(t.form_factor || '') + (t.speed_gbps ? ' · ' + t.speed_gbps + 'G' : '') + '
' + + (t.street_price_usd ? '
$' + parseFloat(t.street_price_usd).toFixed(2) + '
' : '') + + '
'; + }); + html += '
'; + } + + if (useKb && kbData && kbData.entries && kbData.entries.length) { + html += '
Knowledge Base
'; + kbData.entries.slice(0,4).forEach(function(e) { + html += '
' + + '
' + esc(e.question) + '
' + + '
' + esc((e.answer||'').slice(0,80)) + '…
' + + '
'; + }); + html += '
'; + } + + if (useNews && newsData && newsData.results && newsData.results.length) { + html += '
News
'; + newsData.results.slice(0,4).forEach(function(n) { + html += '
' + + '
' + esc(n.title || '') + '
' + + '
' + esc(n.source || '') + '
' + + '
'; + }); + } + + if (!html) html = '
No results for "' + esc(q) + '"
'; + buildDOM(res, html); +} + +// Keyboard shortcut Cmd+K / Ctrl+K +document.addEventListener('keydown', function(e) { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openGlobalSearch(); } + if (e.key === 'Escape') { closeGlobalSearch(); closeComparison(); } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// N – SAVED FILTER PRESETS (localStorage) +// ═══════════════════════════════════════════════════════════════════════════ +function getPresets() { + try { return JSON.parse(localStorage.getItem('tip_presets') || '{}'); } catch(e) { return {}; } +} + +function renderPresetSelect() { + var sel = el('tx-preset-select'); + if (!sel) return; + var presets = getPresets(); + var html = ''; + Object.keys(presets).forEach(function(name) { + html += ''; + }); + html += Object.keys(presets).length ? '' : ''; + buildDOM(sel, html); +} + +function savePreset() { + var name = prompt('Preset name:'); + if (!name) return; + var presets = getPresets(); + presets[name] = { + q: el('tx-search') ? el('tx-search').value : '', + ff: el('tx-ff-filter') ? el('tx-ff-filter').value : '', + spd: el('tx-speed-filter') ? el('tx-speed-filter').value : '', + fiber: el('tx-fiber-filter') ? el('tx-fiber-filter').value : '', + verified: el('tx-verified-only') ? el('tx-verified-only').checked : false, + vendor: el('tx-vendor-filter') ? el('tx-vendor-filter').value : '', + }; + try { localStorage.setItem('tip_presets', JSON.stringify(presets)); } catch(e) {} + renderPresetSelect(); +} + +function loadPreset(name) { + if (!name) return; + if (name === '__delete__') { + var sel = el('tx-preset-select'); + if (!sel) return; + // Ask which to delete + var presets = getPresets(); + var toDelete = prompt('Delete preset name:\n' + Object.keys(presets).join(', ')); + if (!toDelete || !presets[toDelete]) return; + delete presets[toDelete]; + try { localStorage.setItem('tip_presets', JSON.stringify(presets)); } catch(e) {} + renderPresetSelect(); + return; + } + var presets = getPresets(); + var p = presets[name]; + if (!p) return; + if (el('tx-search')) el('tx-search').value = p.q || ''; + if (el('tx-ff-filter')) el('tx-ff-filter').value = p.ff || ''; + if (el('tx-speed-filter')) el('tx-speed-filter').value = p.spd || ''; + if (el('tx-fiber-filter')) el('tx-fiber-filter').value = p.fiber || ''; + if (el('tx-verified-only')) el('tx-verified-only').checked = !!p.verified; + if (el('tx-vendor-filter')) el('tx-vendor-filter').value = p.vendor || ''; + searchTransceivers(); +} +renderPresetSelect(); + +// ═══════════════════════════════════════════════════════════════════════════ +// O – PRICE FORECAST (overlay dashed line on price history chart) +// ═══════════════════════════════════════════════════════════════════════════ +async function overlayForecast(txId, svgEl, W, PL, PR, PT, PB, H, minY, maxY, minDay, maxDay) { + try { + var d = await api('/api/price-forecast/' + txId); + if (!d || !d.forecast || !d.forecast.length) return; + var forecast = d.forecast; + var rangeY = maxY - minY || 1; + var totalDays = maxDay - minDay || 1; + function xPos(dateStr) { + var ms = new Date(dateStr).getTime(); + var t = (ms - minDay) / totalDays; + return PL + t * (W - PL - PR); + } + function yPos(price) { + return PT + (1 - (price - minY) / rangeY) * (H - PT - PB); + } + // Build polyline for forecast + var points = forecast.map(function(f) { + return xPos(f.date) + ',' + yPos(f.predicted_price); + }).join(' '); + var polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + polyline.setAttribute('points', points); + polyline.setAttribute('fill', 'none'); + polyline.setAttribute('stroke', '#94a3b8'); + polyline.setAttribute('stroke-width', '1.5'); + polyline.setAttribute('stroke-dasharray', '4 3'); + polyline.setAttribute('opacity', '0.7'); + svgEl.appendChild(polyline); + // Trend label + var trendColors = { rising:'#22c55e', declining:'#ef4444', stable:'#94a3b8' }; + var label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + label.setAttribute('x', W - PR - 2); + label.setAttribute('y', PT + 14); + label.setAttribute('text-anchor', 'end'); + label.setAttribute('font-size', '10'); + label.setAttribute('fill', trendColors[d.trend] || '#94a3b8'); + label.textContent = (d.trend === 'rising' ? '▲' : d.trend === 'declining' ? '▼' : '→') + ' ' + d.trend + ' (30d forecast)'; + svgEl.appendChild(label); + } catch(e) { /* forecast optional */ } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// P – TECHNOLOGY RADAR (SVG, injected into hype tab) +// ═══════════════════════════════════════════════════════════════════════════ +async function loadTechRadar() { + var container = el('tech-radar-container'); + if (!container) return; + container.innerHTML = '
Building Technology Radar…
'; + try { + var d = await api('/api/hype-cycle'); + var techs = d.technologies || d || []; + renderRadar(techs, container); + } catch(e) { + container.innerHTML = '
Failed to load radar data.
'; + } +} + +function renderRadar(techs, container) { + var CX = 280, CY = 280, R = 240; + var rings = [ + { name: 'ADOPT', r: R * 0.25, color: '#22c55e', phases: ['mainstream','slope_of_enlightenment'] }, + { name: 'TRIAL', r: R * 0.5, color: '#3b82f6', phases: ['peak','early_mainstream'] }, + { name: 'ASSESS', r: R * 0.75, color: '#f59e0b', phases: ['innovation_trigger','rising_expectations'] }, + { name: 'HOLD', r: R, color: '#ef4444', phases: ['trough','plateau','declining'] }, + ]; + + var phaseMap = {}; + rings.forEach(function(rng) { + (rng.phases || []).forEach(function(p) { phaseMap[p] = rng; }); + }); + + var svg = '' + + ''; + + // Rings + rings.slice().reverse().forEach(function(rng) { + svg += ''; + svg += '' + rng.name + ''; + }); + + // Quadrant dividers + svg += ''; + svg += ''; + + // Quadrant labels + var quadLabels = [['High\nSpeed', -1, -1], ['Coherent', 1, -1], ['Short\nReach', 1, 1], ['Campus\n& Edge', -1, 1]]; + quadLabels.forEach(function(ql) { + svg += '' + ql[0].split('\n').map(function(t,i){ return '' + t + ''; }).join('') + ''; + }); + + // Place techs in rings with jitter + var placed = {}; + techs.forEach(function(t, i) { + var rng = phaseMap[t.phase] || rings[3]; + var innerR = (i > 0 ? rings.indexOf(rng) > 0 ? rings[rings.indexOf(rng)-1].r : 0 : 0); + var outerR = rng.r; + var angle = (i / techs.length) * 2 * Math.PI - Math.PI / 4; + var r2 = innerR + (outerR - innerR) * (0.3 + 0.6 * ((i * 137.5 % 360) / 360)); + var x = CX + r2 * Math.cos(angle); + var y = CY + r2 * Math.sin(angle); + var score = t.market_signal_score || t.hype_score || 50; + var dotR = 4 + Math.min(score, 100) / 20; + svg += '' + + '' + + '' + esc(t.name || t.technology) + '\nPhase: ' + esc(t.phase || '—') + '\nSignal: ' + score + '' + + '' + + '' + esc((t.name || t.technology || '').substring(0, 14)) + '' + + ''; + }); + + svg += ''; + + // Legend + var legend = '
' + + rings.map(function(rng) { + return '
' + rng.name + '
'; + }).join('') + '
'; + + buildDOM(container, svg + legend); +} +