feat: 10 weitere Dashboard-Features (G–P)
G) SKU Bulk Pricer — POST /api/bulk-price (bis 100 Part Numbers), Preistabelle je Vendor, CSV-Export, Not-Found-Liste H) Side-by-side Comparison — Checkboxes in TX-Tabelle, Floating Comparison-Tray (max 4 SKUs), Modal mit Specs + Best-Price 7d nebeneinander I) Vendor Reliability Score — GET /api/vendors/reliability, Freshness(40) + Frequency(30) + Coverage(30) = 0–100, Progress-Bar-Badge auf Vendor-Cards J) Price Heat Map — GET /api/price-matrix?ids=, Row-normalisierte Farbmatrix SKU×Vendor (grün=günstig/rot=teuer), Sticky SKU-Spalte, Best-Price-Spalte K) Watchlist — localStorage-basiert (⭐/☆ in TX-Tabelle), Floating Drawer, live Preis-Update via Einzelabruf L) PDF / Print Report — window.print() + dediziertes @media print CSS (blendet UI-Chrome aus, behält Overview-Content) M) Global Search Overlay — Cmd+K / Ctrl+K, durchsucht Transceivers + KB + News + Documents gleichzeitig, clickbare Direktlinks N) Saved Filter Presets — localStorage tip_presets, Dropdown + 💾-Button in TX-Filterzeile, Save/Load/Delete O) Price Forecast — GET /api/price-forecast/:id (lineare Regression 90d → 30d Forecast), gestricheltes Overlay auf Price-History-Chart, Trend-Label (rising/stable/declining) P) Technology Radar — SVG Bull's-Eye (Adopt/Trial/Assess/Hold), Hype-Cycle-Phasen → Ringe gemappt, Bubbles mit Market-Signal-Score, Quadrant-Labels, interaktive Tooltips
This commit is contained in:
parent
fb060ee40a
commit
e71b985c52
@ -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")));
|
||||
|
||||
156
packages/api/src/routes/bulk-price.ts
Normal file
156
packages/api/src/routes/bulk-price.ts
Normal file
@ -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<string, ResultEntry>();
|
||||
|
||||
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) });
|
||||
}
|
||||
});
|
||||
111
packages/api/src/routes/price-forecast.ts
Normal file
111
packages/api/src/routes/price-forecast.ts
Normal file
@ -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) });
|
||||
}
|
||||
});
|
||||
136
packages/api/src/routes/price-matrix.ts
Normal file
136
packages/api/src/routes/price-matrix.ts
Normal file
@ -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<number, string>();
|
||||
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<string, Record<string, number>> = {};
|
||||
const bestPrices: Record<string, number> = {};
|
||||
|
||||
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) });
|
||||
}
|
||||
});
|
||||
83
packages/api/src/routes/vendor-reliability.ts
Normal file
83
packages/api/src/routes/vendor-reliability.ts
Normal file
@ -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) });
|
||||
}
|
||||
});
|
||||
@ -807,11 +807,16 @@
|
||||
<div class="tab" data-tab="prices">💲 Price Comparison</div>
|
||||
<div class="tab" data-tab="equivalences">🔀 Equivalences</div>
|
||||
<div class="tab" data-tab="kb">📚 KB</div>
|
||||
<div class="tab" data-tab="bulk">🧾 Bulk</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- OVERVIEW -->
|
||||
<div id="tab-overview" class="fade-in">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:0.5rem;gap:0.5rem">
|
||||
<button onclick="openGlobalSearch()" style="background:var(--surface2);border:1px solid var(--border);padding:5px 14px;border-radius:6px;cursor:pointer;font-size:0.78rem;color:var(--text-dim)">🔍 Global Search <kbd style="font-size:0.65rem;opacity:0.6">⌘K</kbd></button>
|
||||
<button onclick="exportPDF()" style="background:var(--surface2);border:1px solid var(--border);padding:5px 14px;border-radius:6px;cursor:pointer;font-size:0.78rem;color:var(--text-dim)">📄 Export Report</button>
|
||||
</div>
|
||||
<div class="grid mb" style="grid-template-columns: repeat(5, 1fr);">
|
||||
<div class="stat-card" data-goto="transceivers">
|
||||
<div class="stat-icon blue">⚙</div>
|
||||
@ -1047,10 +1052,26 @@
|
||||
<div class="loading pulse">Loading marketplace data…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TECHNOLOGY RADAR (P) -->
|
||||
<div class="card mt" style="border-left:3px solid #6366f1">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
|
||||
<div>
|
||||
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">📡 Technology Radar</div>
|
||||
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Hype Cycle phases mapped to Adopt / Trial / Assess / Hold rings</div>
|
||||
</div>
|
||||
<button onclick="loadTechRadar()" style="background:var(--accent);color:#fff;border:none;padding:5px 14px;border-radius:6px;cursor:pointer;font-size:0.78rem">Render Radar</button>
|
||||
</div>
|
||||
<div id="tech-radar-container">
|
||||
<div style="color:var(--text-dim);font-size:0.82rem;padding:1.5rem;text-align:center">Click "Render Radar" to generate the interactive technology radar.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TECHNOLOGY RADAR (P) injected via JS into tab-hype -->
|
||||
|
||||
<!-- TRANSCEIVERS -->
|
||||
<div id="tab-transceivers" class="hidden">
|
||||
<div class="search-row">
|
||||
@ -1093,6 +1114,12 @@
|
||||
<input type="checkbox" id="tx-verified-only" onchange="searchTransceivers()"> Verified only
|
||||
</label>
|
||||
<input type="hidden" id="tx-verified-filter" value="">
|
||||
<div style="display:flex;align-items:center;gap:4px">
|
||||
<select id="tx-preset-select" onchange="loadPreset(this.value)" style="font-size:0.72rem;padding:2px 6px;border-radius:4px;border:1px solid var(--border);background:var(--surface2);color:var(--text-dim);max-width:110px">
|
||||
<option value="">Presets…</option>
|
||||
</select>
|
||||
<button onclick="savePreset()" title="Save current filter as preset" style="background:none;border:1px solid var(--border);border-radius:4px;padding:1px 7px;cursor:pointer;font-size:0.72rem;color:var(--text-dim)">💾</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.4rem">
|
||||
<span id="tx-result-count" style="font-size:0.75rem;color:var(--text-dim)"></span>
|
||||
@ -1669,6 +1696,7 @@
|
||||
<button onclick="showProcSection('dead-stock')" id="proc-btn-dead-stock" class="proc-btn" style="background:rgba(245,158,11,0.08);border-color:rgba(245,158,11,0.3);color:#f59e0b">🪦 Dead Stock Revival</button>
|
||||
<button onclick="showProcSection('movers')" id="proc-btn-movers" class="proc-btn" style="background:rgba(99,102,241,0.08);border-color:rgba(99,102,241,0.3);color:#6366f1">📈 Price Movers</button>
|
||||
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
|
||||
<button onclick="showProcSection('heatmap')" id="proc-btn-heatmap" class="proc-btn" style="background:rgba(6,182,212,0.08);border-color:rgba(6,182,212,0.3);color:#06b6d4">🌡 Price Matrix</button>
|
||||
<button onclick="showProcSection('demand')" id="proc-btn-demand" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">📦 Internal Demand</button>
|
||||
<button onclick="showProcSection('ai-clusters')" id="proc-btn-ai-clusters" class="proc-btn" style="background:rgba(124,92,252,0.08);border-color:rgba(124,92,252,0.3);color:#7c5cfc">🤖 AI Clusters</button>
|
||||
<button onclick="showProcSection('marketplace')" id="proc-btn-marketplace" class="proc-btn" style="background:rgba(249,115,22,0.08);border-color:rgba(249,115,22,0.3);color:#f97316">🛒 eBay Market</button>
|
||||
@ -1865,6 +1893,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PRICE MATRIX -->
|
||||
<div id="proc-section-heatmap" style="display:none">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;flex-wrap:wrap;gap:0.5rem">
|
||||
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">🌡 Price Matrix <span style="font-size:0.72rem;font-weight:400;color:var(--text-dim)">Latest price per SKU × Vendor</span></div>
|
||||
<input id="heatmap-ids" type="text" placeholder="Paste transceiver IDs (comma-sep) or leave empty for top 12" style="font-size:0.78rem;padding:5px 10px;border-radius:6px;border:1px solid var(--border);background:var(--surface2);color:var(--text-bright);width:340px">
|
||||
<button onclick="loadHeatMap()" style="background:var(--accent);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:0.78rem">Load Matrix</button>
|
||||
</div>
|
||||
<div id="heatmap-container" style="overflow-x:auto">
|
||||
<div style="color:var(--text-dim);font-size:0.8rem;padding:1.5rem">Click "Load Matrix" to render the price heat map.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end tab-procurement -->
|
||||
|
||||
<!-- CRAWLER INTELLIGENCE -->
|
||||
@ -2511,6 +2551,77 @@
|
||||
</div>
|
||||
</div><!-- end tab-kb -->
|
||||
|
||||
<!-- ── BULK PRICER (G) ─────────────────────────────────────────────── -->
|
||||
<div id="tab-bulk" class="hidden fade-in">
|
||||
<div class="card" style="max-width:900px;margin:0 auto">
|
||||
<div style="margin-bottom:1rem">
|
||||
<div style="font-size:1.1rem;font-weight:700;color:var(--text-bright);margin-bottom:0.25rem">🧾 SKU Bulk Pricer</div>
|
||||
<div style="font-size:0.8rem;color:var(--text-dim)">Paste part numbers (one per line or comma-separated). Returns live market prices from all vendor sources.</div>
|
||||
</div>
|
||||
<textarea id="bulk-input" rows="6" placeholder="SFP-10G-SR QSFP-100G-LR4 SFP-1G-T ..." style="width:100%;box-sizing:border-box;padding:0.75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface2);color:var(--text-bright);font-family:var(--mono);font-size:0.82rem;resize:vertical"></textarea>
|
||||
<div style="display:flex;gap:0.75rem;margin-top:0.75rem;align-items:center">
|
||||
<button onclick="runBulkPrice()" style="background:var(--accent);color:#fff;border:none;padding:8px 20px;border-radius:8px;cursor:pointer;font-size:0.85rem;font-weight:600">Fetch Prices</button>
|
||||
<button onclick="exportBulkCSV()" id="bulk-export-btn" style="display:none;background:var(--surface2);border:1px solid var(--border);padding:8px 16px;border-radius:8px;cursor:pointer;font-size:0.82rem;color:var(--text-dim)">📥 Export CSV</button>
|
||||
<span id="bulk-status" style="font-size:0.78rem;color:var(--text-dim)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bulk-results" style="margin-top:1rem"></div>
|
||||
</div><!-- end tab-bulk -->
|
||||
|
||||
<!-- ── GLOBAL SEARCH OVERLAY (M) ──────────────────────────────────── -->
|
||||
<div id="global-search-overlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.7);backdrop-filter:blur(4px)" onclick="if(event.target===this)closeGlobalSearch()">
|
||||
<div style="position:absolute;top:15%;left:50%;transform:translateX(-50%);width:min(700px,94vw);background:var(--surface1);border:1px solid var(--border);border-radius:14px;padding:1.25rem;box-shadow:0 24px 80px rgba(0,0,0,0.5)">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<span style="font-size:1.2rem">🔍</span>
|
||||
<input id="gs-input" type="text" placeholder="Search transceivers, KB, news, documents…" oninput="debounceGS()" onkeydown="if(event.key==='Escape')closeGlobalSearch()" autocomplete="off"
|
||||
style="flex:1;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--surface2);color:var(--text-bright);font-size:1rem">
|
||||
<button onclick="closeGlobalSearch()" style="background:none;border:none;cursor:pointer;color:var(--text-dim);font-size:1.2rem">✕</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;flex-wrap:wrap">
|
||||
<label style="font-size:0.72rem;color:var(--text-dim);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="gs-tx" checked> Products</label>
|
||||
<label style="font-size:0.72rem;color:var(--text-dim);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="gs-kb" checked> KB</label>
|
||||
<label style="font-size:0.72rem;color:var(--text-dim);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="gs-news" checked> News</label>
|
||||
<label style="font-size:0.72rem;color:var(--text-dim);display:flex;align-items:center;gap:4px;cursor:pointer"><input type="checkbox" id="gs-docs"> Documents</label>
|
||||
</div>
|
||||
<div id="gs-results" style="max-height:420px;overflow-y:auto">
|
||||
<div style="color:var(--text-dim);font-size:0.85rem;padding:1.5rem;text-align:center">Type at least 2 characters to search…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── COMPARISON TRAY (H) ─────────────────────────────────────────── -->
|
||||
<div id="compare-tray" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:8888;background:var(--surface1);border-top:2px solid var(--accent);padding:0.75rem 1.25rem;box-shadow:0 -8px 32px rgba(0,0,0,0.35)">
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||
<span style="font-size:0.8rem;font-weight:600;color:var(--accent)">Compare</span>
|
||||
<div id="compare-slots" style="display:flex;gap:0.5rem;flex:1;flex-wrap:wrap"></div>
|
||||
<button onclick="openComparison()" style="background:var(--accent);color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:0.82rem;font-weight:600">Compare ▸</button>
|
||||
<button onclick="clearCompare()" style="background:none;border:1px solid var(--border);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:0.78rem;color:var(--text-dim)">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── COMPARISON MODAL (H) ────────────────────────────────────────── -->
|
||||
<div id="compare-modal" style="display:none;position:fixed;inset:0;z-index:9990;background:rgba(0,0,0,0.75);overflow:auto" onclick="if(event.target===this)closeComparison()">
|
||||
<div style="margin:2rem auto;max-width:1200px;background:var(--surface1);border-radius:14px;padding:1.5rem;border:1px solid var(--border)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<div style="font-size:1.1rem;font-weight:700;color:var(--text-bright)">⚖️ Side-by-Side Comparison</div>
|
||||
<button onclick="closeComparison()" style="background:none;border:none;cursor:pointer;color:var(--text-dim);font-size:1.2rem">✕</button>
|
||||
</div>
|
||||
<div id="compare-body" style="overflow-x:auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── WATCHLIST DRAWER (K) ────────────────────────────────────────── -->
|
||||
<div id="watchlist-btn" onclick="toggleWatchlist()" title="Watchlist" style="position:fixed;right:1rem;bottom:4.5rem;z-index:8887;background:var(--surface1);border:1px solid var(--border);border-radius:50%;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,0.3);font-size:1.2rem">⭐</div>
|
||||
<div id="watchlist-count-badge" style="display:none;position:fixed;right:0.75rem;bottom:6.8rem;z-index:8888;background:var(--accent);color:#fff;border-radius:10px;padding:1px 6px;font-size:0.65rem;font-weight:700"></div>
|
||||
<div id="watchlist-drawer" style="display:none;position:fixed;right:0;top:0;bottom:0;z-index:9000;width:340px;background:var(--surface1);border-left:1px solid var(--border);box-shadow:-8px 0 40px rgba(0,0,0,0.4);overflow-y:auto;padding:1.25rem">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<div style="font-weight:700;color:var(--text-bright)">⭐ Watchlist</div>
|
||||
<button onclick="toggleWatchlist()" style="background:none;border:none;cursor:pointer;color:var(--text-dim);font-size:1.1rem">✕</button>
|
||||
</div>
|
||||
<div id="watchlist-items"></div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div><!-- .app -->
|
||||
@ -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, '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.75rem">' + total + ' entries</div>' + 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 = '<div class="loading pulse">Querying market data…</div>';
|
||||
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 += '<div style="background:#f9731622;border:1px solid #f9731644;border-radius:8px;padding:0.6rem 1rem;margin-bottom:0.75rem;font-size:0.78rem;color:#f97316">'
|
||||
+ '<b>Not found:</b> ' + d.not_found.map(esc).join(', ') + '</div>';
|
||||
}
|
||||
(d.results || []).forEach(function(r) {
|
||||
html += '<div class="card mb" style="padding:1rem">'
|
||||
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.6rem">'
|
||||
+ '<div>'
|
||||
+ '<div style="font-weight:700;color:var(--text-bright);font-family:var(--mono)">' + esc(r.part_number) + '</div>'
|
||||
+ (r.model_name ? '<div style="font-size:0.72rem;color:var(--text-dim)">' + esc(r.model_name) + '</div>' : '')
|
||||
+ '</div>'
|
||||
+ '<div style="display:flex;gap:0.4rem">'
|
||||
+ (r.form_factor ? '<span class="b b-blue">' + esc(r.form_factor) + '</span>' : '')
|
||||
+ (r.speed_gbps ? '<span class="b b-neutral">' + r.speed_gbps + 'G</span>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
if (!r.prices || !r.prices.length) {
|
||||
html += '<div style="font-size:0.8rem;color:var(--text-dim)">No current pricing data available.</div>';
|
||||
} else {
|
||||
html += '<table style="width:100%;border-collapse:collapse;font-size:0.8rem">'
|
||||
+ '<thead><tr><th style="text-align:left;color:var(--text-dim);font-weight:500;padding:3px 6px;border-bottom:1px solid var(--border)">Vendor</th><th style="text-align:right;color:var(--text-dim);font-weight:500;padding:3px 6px;border-bottom:1px solid var(--border)">Price</th><th style="text-align:right;color:var(--text-dim);font-weight:500;padding:3px 6px;border-bottom:1px solid var(--border)">Currency</th><th style="text-align:right;color:var(--text-dim);font-weight:500;padding:3px 6px;border-bottom:1px solid var(--border)">Observed</th></tr></thead><tbody>';
|
||||
var best = parseFloat(r.best_price_usd || 0);
|
||||
r.prices.forEach(function(p) {
|
||||
var isBest = parseFloat(p.price || p.price_usd || 0) === best;
|
||||
html += '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:4px 6px;' + (isBest ? 'color:var(--green);font-weight:600' : 'color:var(--text-bright)') + '">' + esc(p.vendor_name || '—') + (isBest ? ' ★' : '') + '</td>'
|
||||
+ '<td style="text-align:right;padding:4px 6px;font-family:var(--mono);' + (isBest ? 'color:var(--green);font-weight:700' : '') + '">$' + parseFloat(p.price || p.price_usd || 0).toFixed(2) + '</td>'
|
||||
+ '<td style="text-align:right;padding:4px 6px;color:var(--text-dim)">' + esc(p.currency || 'USD') + '</td>'
|
||||
+ '<td style="text-align:right;padding:4px 6px;color:var(--text-dim);font-size:0.72rem">' + (p.observed_at ? new Date(p.observed_at).toLocaleDateString() : '—') + '</td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
if (res) buildDOM(res, html || '<div style="color:var(--text-dim);padding:1.5rem">No results.</div>');
|
||||
if (exportBtn && d.results && d.results.length) exportBtn.style.display = '';
|
||||
} catch(e) {
|
||||
if (status) status.textContent = 'Error: ' + String(e);
|
||||
if (res) res.innerHTML = '<div style="color:var(--text-dim);padding:1.5rem">Request failed.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 '<span style="background:var(--accent);color:#fff;padding:3px 10px;border-radius:6px;font-size:0.75rem;display:flex;align-items:center;gap:5px">'
|
||||
+ esc(name) + '<span onclick="removeFromCompare(\'' + id + '\')" style="cursor:pointer;opacity:0.7;font-size:0.85rem">✕</span></span>';
|
||||
});
|
||||
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 = '<div class="loading pulse">Loading comparison data…</div>';
|
||||
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 = '<table style="border-collapse:collapse;width:100%;min-width:600px">'
|
||||
+ '<thead><tr><th style="text-align:left;padding:8px 12px;border-bottom:2px solid var(--border);color:var(--text-dim);font-size:0.75rem;width:140px">Attribute</th>'
|
||||
+ items.map(function(t,i) {
|
||||
return '<th style="text-align:left;padding:8px 12px;border-bottom:2px solid var(--accent);border-left:1px solid var(--border);font-size:0.82rem;color:var(--text-bright);min-width:180px">'
|
||||
+ esc(t && (t.part_number || t.model_name) ? (t.part_number || t.model_name) : 'SKU ' + (i+1)) + '</th>';
|
||||
}).join('')
|
||||
+ '</tr></thead><tbody>';
|
||||
fields.forEach(function(f, fi) {
|
||||
html += '<tr style="background:' + (fi%2===0 ? 'var(--surface2)' : 'var(--surface1)') + '">'
|
||||
+ '<td style="padding:7px 12px;font-size:0.75rem;color:var(--text-dim);font-weight:500">' + f.label + '</td>'
|
||||
+ items.map(function(t) {
|
||||
var v = t ? (f.fn ? f.fn(t) : (t[f.key] || '—')) : '—';
|
||||
return '<td style="padding:7px 12px;font-size:0.82rem;color:var(--text-bright);border-left:1px solid var(--border)">' + esc(String(v)) + '</td>';
|
||||
}).join('')
|
||||
+ '</tr>';
|
||||
});
|
||||
// Best current price row
|
||||
html += '<tr style="background:var(--surface2)">'
|
||||
+ '<td style="padding:7px 12px;font-size:0.75rem;color:var(--text-dim);font-weight:500">Best Price (7d)</td>'
|
||||
+ 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 '<td style="padding:7px 12px;font-size:0.82rem;color:var(--green);font-family:var(--mono);font-weight:600;border-left:1px solid var(--border)">' + best + '</td>';
|
||||
}).join('')
|
||||
+ '</tr>';
|
||||
html += '</tbody></table>';
|
||||
buildDOM(body, html);
|
||||
} catch(e) {
|
||||
body.innerHTML = '<div style="color:var(--text-dim);padding:2rem">Failed to load comparison data.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<div style="flex:1;background:var(--surface3);border-radius:4px;height:4px"><div style="width:' + score + '%;height:100%;background:' + color + ';border-radius:4px"></div></div>'
|
||||
+ '<span style="color:' + color + ';font-weight:700;font-family:var(--mono);white-space:nowrap">' + score + '/100</span>';
|
||||
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 = '<div class="loading pulse">Loading price matrix…</div>';
|
||||
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 = '<div style="color:var(--text-dim);padding:1.5rem">Failed to load price matrix: ' + esc(String(e)) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeatMap(d, container) {
|
||||
if (!d || !d.transceivers || !d.vendors || !d.matrix) {
|
||||
container.innerHTML = '<div style="color:var(--text-dim);padding:1.5rem">No matrix data available.</div>';
|
||||
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 = '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">Color: 🟢 cheapest → 🔴 most expensive per row. Gray = no data.</div>';
|
||||
html += '<table style="border-collapse:collapse;font-size:0.75rem;min-width:600px">'
|
||||
+ '<thead><tr>'
|
||||
+ '<th style="text-align:left;padding:6px 10px;border-bottom:2px solid var(--border);color:var(--text-dim);position:sticky;left:0;background:var(--surface1);z-index:1;min-width:160px">SKU</th>'
|
||||
+ vendors.map(function(v) {
|
||||
return '<th style="text-align:center;padding:6px 8px;border-bottom:2px solid var(--border);color:var(--text-dim);white-space:nowrap;max-width:100px;overflow:hidden;text-overflow:ellipsis" title="' + esc(v.vendor_name || '') + '">' + esc((v.vendor_name || '').substring(0, 12)) + '</th>';
|
||||
}).join('')
|
||||
+ '<th style="text-align:center;padding:6px 8px;border-bottom:2px solid var(--border);color:var(--green);font-weight:700">Best</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
|
||||
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 += '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:5px 10px;color:var(--text-bright);font-family:var(--mono);position:sticky;left:0;background:var(--surface' + (ri%2===0?'1':'2') + ');cursor:pointer" onclick="openTxDetail(\'' + esc(String(t.id)) + '\')">' + esc(t.model_name || t.part_number || String(t.id)) + '</td>';
|
||||
vendors.forEach(function(v) {
|
||||
var raw = matrix[t.id] && matrix[t.id][v.vendor_id];
|
||||
if (!raw) {
|
||||
html += '<td style="text-align:center;padding:5px 8px;color:var(--text-dim)">—</td>';
|
||||
} 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 += '<td style="text-align:center;padding:5px 8px;background:' + bg + ';color:' + fc + ';font-family:var(--mono);font-weight:600">$' + p.toFixed(2) + '</td>';
|
||||
}
|
||||
});
|
||||
var best = bestPrices[t.id];
|
||||
html += '<td style="text-align:center;padding:5px 8px;color:var(--green);font-family:var(--mono);font-weight:700">' + (best ? '$' + parseFloat(best).toFixed(2) : '—') + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
html += '<div style="font-size:0.7rem;color:var(--text-dim);margin-top:0.5rem">' + txs.length + ' SKUs × ' + vendors.length + ' vendors</div>';
|
||||
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 = '<div style="color:var(--text-dim);font-size:0.8rem;padding:1rem 0">No items. Click ⭐ on any transceiver row to add it.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = '<div class="loading pulse">Loading…</div>';
|
||||
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 '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.7rem 0.85rem;margin-bottom:0.5rem;display:flex;align-items:center;gap:0.75rem">'
|
||||
+ '<div style="flex:1;cursor:pointer" onclick="openTxDetail(\'' + esc(String(t.id || t.transceiver_id)) + '\')">'
|
||||
+ '<div style="font-size:0.82rem;font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.model_name || String(t.id)) + '</div>'
|
||||
+ (t.form_factor ? '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(t.form_factor) + (t.speed_gbps ? ' · ' + t.speed_gbps + 'G' : '') + '</div>' : '')
|
||||
+ (t.street_price_usd ? '<div style="font-size:0.78rem;color:var(--green);font-family:var(--mono);font-weight:700">$' + parseFloat(t.street_price_usd).toFixed(2) + '</div>' : '')
|
||||
+ '</div>'
|
||||
+ '<button onclick="toggleWatchlistItem(\'' + esc(String(t.id || t.transceiver_id)) + '\')" style="background:none;border:none;cursor:pointer;color:#f59e0b;font-size:1.1rem" title="Remove">★</button>'
|
||||
+ '</div>';
|
||||
}).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 = '<span class="wl-star" onclick="event.stopPropagation();toggleWatchlistItem(\'' + id + '\')" style="cursor:pointer;font-size:0.9rem;color:' + (window._watchlist.has(id) ? '#f59e0b' : 'var(--text-dim)') + '" title="Watchlist">' + (window._watchlist.has(id) ? '★' : '☆') + '</span>';
|
||||
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 = '<div style="color:var(--text-dim);font-size:0.85rem;padding:1.5rem;text-align:center">Type at least 2 characters to search…</div>';
|
||||
return;
|
||||
}
|
||||
res.innerHTML = '<div class="loading pulse">Searching…</div>';
|
||||
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 += '<div style="font-size:0.7rem;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.35rem">Products</div>';
|
||||
txData.data.slice(0,5).forEach(function(t) {
|
||||
html += '<div onclick="closeGlobalSearch();openTxDetail(\'' + esc(String(t.id)) + '\')" style="cursor:pointer;padding:8px 10px;border-radius:7px;margin-bottom:3px;background:var(--surface2);border:1px solid var(--border);display:flex;align-items:center;gap:0.75rem">'
|
||||
+ '<span style="font-size:0.75rem;color:var(--text-dim)">🔌</span>'
|
||||
+ '<div><div style="font-size:0.85rem;font-weight:600;color:var(--text-bright)">' + esc(t.part_number || t.model_name) + '</div>'
|
||||
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + esc(t.form_factor || '') + (t.speed_gbps ? ' · ' + t.speed_gbps + 'G' : '') + '</div></div>'
|
||||
+ (t.street_price_usd ? '<div style="margin-left:auto;font-family:var(--mono);font-size:0.8rem;color:var(--green);font-weight:700">$' + parseFloat(t.street_price_usd).toFixed(2) + '</div>' : '')
|
||||
+ '</div>';
|
||||
});
|
||||
html += '<div style="height:0.5rem"></div>';
|
||||
}
|
||||
|
||||
if (useKb && kbData && kbData.entries && kbData.entries.length) {
|
||||
html += '<div style="font-size:0.7rem;font-weight:700;color:#a855f7;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.35rem">Knowledge Base</div>';
|
||||
kbData.entries.slice(0,4).forEach(function(e) {
|
||||
html += '<div onclick="closeGlobalSearch();goToTab(\'kb\');setTimeout(function(){el(\'kb-q\').value=' + "'" + esc(q) + "'" + ';searchKB();},200)" style="cursor:pointer;padding:8px 10px;border-radius:7px;margin-bottom:3px;background:var(--surface2);border:1px solid var(--border)">'
|
||||
+ '<div style="font-size:0.82rem;color:var(--text-bright)">' + esc(e.question) + '</div>'
|
||||
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-top:2px">' + esc((e.answer||'').slice(0,80)) + '…</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
html += '<div style="height:0.5rem"></div>';
|
||||
}
|
||||
|
||||
if (useNews && newsData && newsData.results && newsData.results.length) {
|
||||
html += '<div style="font-size:0.7rem;font-weight:700;color:#f97316;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.35rem">News</div>';
|
||||
newsData.results.slice(0,4).forEach(function(n) {
|
||||
html += '<div onclick="closeGlobalSearch();goToTab(\'news\')" style="cursor:pointer;padding:8px 10px;border-radius:7px;margin-bottom:3px;background:var(--surface2);border:1px solid var(--border)">'
|
||||
+ '<div style="font-size:0.82rem;color:var(--text-bright)">' + esc(n.title || '') + '</div>'
|
||||
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-top:2px">' + esc(n.source || '') + '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
if (!html) html = '<div style="color:var(--text-dim);font-size:0.85rem;padding:1.5rem;text-align:center">No results for "' + esc(q) + '"</div>';
|
||||
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 = '<option value="">Presets…</option>';
|
||||
Object.keys(presets).forEach(function(name) {
|
||||
html += '<option value="' + esc(name) + '">' + esc(name) + '</option>';
|
||||
});
|
||||
html += Object.keys(presets).length ? '<option value="__delete__">⚠ Delete selected…</option>' : '';
|
||||
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 = '<div class="loading pulse">Building Technology Radar…</div>';
|
||||
try {
|
||||
var d = await api('/api/hype-cycle');
|
||||
var techs = d.technologies || d || [];
|
||||
renderRadar(techs, container);
|
||||
} catch(e) {
|
||||
container.innerHTML = '<div style="color:var(--text-dim);padding:1.5rem">Failed to load radar data.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
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 = '<svg viewBox="0 0 560 560" style="max-width:560px;width:100%;display:block;margin:0 auto">'
|
||||
+ '<circle cx="' + CX + '" cy="' + CY + '" r="' + R + '" fill="var(--surface2)" stroke="var(--border)" stroke-width="1"/>';
|
||||
|
||||
// Rings
|
||||
rings.slice().reverse().forEach(function(rng) {
|
||||
svg += '<circle cx="' + CX + '" cy="' + CY + '" r="' + rng.r + '" fill="' + rng.color + '18" stroke="' + rng.color + '" stroke-width="1" stroke-dasharray="4 3"/>';
|
||||
svg += '<text x="' + (CX + rng.r - 4) + '" y="' + (CY - 6) + '" text-anchor="end" font-size="9" font-weight="700" fill="' + rng.color + '" opacity="0.7">' + rng.name + '</text>';
|
||||
});
|
||||
|
||||
// Quadrant dividers
|
||||
svg += '<line x1="' + CX + '" y1="' + (CY - R) + '" x2="' + CX + '" y2="' + (CY + R) + '" stroke="var(--border)" stroke-width="1" opacity="0.5"/>';
|
||||
svg += '<line x1="' + (CX - R) + '" y1="' + CY + '" x2="' + (CX + R) + '" y2="' + CY + '" stroke="var(--border)" stroke-width="1" opacity="0.5"/>';
|
||||
|
||||
// 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 += '<text x="' + (CX + ql[1] * R * 0.62) + '" y="' + (CY + ql[2] * R * 0.62) + '" text-anchor="middle" font-size="10" fill="var(--text-dim)" opacity="0.5">' + ql[0].split('\n').map(function(t,i){ return '<tspan x="' + (CX + ql[1] * R * 0.62) + '" dy="' + (i===0?'0':'1.2em') + '">' + t + '</tspan>'; }).join('') + '</text>';
|
||||
});
|
||||
|
||||
// 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 += '<g onclick="void(0)" style="cursor:pointer">'
|
||||
+ '<circle cx="' + x.toFixed(1) + '" cy="' + y.toFixed(1) + '" r="' + dotR.toFixed(1) + '" fill="' + rng.color + '" opacity="0.85">'
|
||||
+ '<title>' + esc(t.name || t.technology) + '\nPhase: ' + esc(t.phase || '—') + '\nSignal: ' + score + '</title>'
|
||||
+ '</circle>'
|
||||
+ '<text x="' + x.toFixed(1) + '" y="' + (y - dotR - 3).toFixed(1) + '" text-anchor="middle" font-size="8.5" fill="var(--text-bright)" font-weight="600">' + esc((t.name || t.technology || '').substring(0, 14)) + '</text>'
|
||||
+ '</g>';
|
||||
});
|
||||
|
||||
svg += '</svg>';
|
||||
|
||||
// Legend
|
||||
var legend = '<div style="display:flex;gap:1rem;justify-content:center;margin-top:0.75rem;flex-wrap:wrap">'
|
||||
+ rings.map(function(rng) {
|
||||
return '<div style="display:flex;align-items:center;gap:0.4rem;font-size:0.72rem"><div style="width:10px;height:10px;border-radius:50%;background:' + rng.color + '"></div><span style="color:var(--text-dim)">' + rng.name + '</span></div>';
|
||||
}).join('') + '</div>';
|
||||
|
||||
buildDOM(container, svg + legend);
|
||||
}
|
||||
|
||||
</script>
|
||||
<script src="/dashboard/hot-topics.js"></script>
|
||||
</body>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user