feat: dynamic hype cycle + market signal engine + eBay/CapEx panels
6-source composite Market Signal Score (0-100) per transceiver technology. New GET /api/hype-cycle/market-signals blends: Norton-Bass hype_score, hyperscaler CapEx YoY (MSFT +68.8%, GOOG +107%, META +46.8%), price observation activity ratio 30d vs prior 30d, AI cluster transceiver demand, eBay secondary market sell-through velocity, internal fast-mover trend. All 6 queries run in parallel via Promise.all(). Recommendation engine maps hype phase × capex boom × speed class → Buy/Hold/Watch labels with tooltips. Dashboard Hype Cycle table now shows Market Signal ● LIVE column + Recommendation column. Hyperscaler CapEx panel + eBay panel added to hype tab. Procurement: new eBay Market section. Sourcing Hype Cycle replaced hardcoded seed with live price observation data.
This commit is contained in:
parent
13fe33eceb
commit
10d13633fb
@ -1,6 +1,7 @@
|
|||||||
# TIP Changelog
|
# TIP Changelog
|
||||||
|
|
||||||
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
||||||
|
{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0–100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class → Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal ● LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."}
|
||||||
{"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."}
|
{"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."}
|
||||||
|
|
||||||
{"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."}
|
{"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."}
|
||||||
|
|||||||
@ -22,25 +22,112 @@ const CLAUDE_BRIDGE_URL = process.env.CLAUDE_BRIDGE_URL || "http://localhost:325
|
|||||||
// ── Runtime-switchable provider state ──────────────────────────────────────
|
// ── Runtime-switchable provider state ──────────────────────────────────────
|
||||||
// Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch).
|
// Reads from /opt/tip/blog-llm-settings.json if present (written by /api/blog/llm/switch).
|
||||||
// Falls back to process.env, then to defaults. No restart required for switches.
|
// Falls back to process.env, then to defaults. No restart required for switches.
|
||||||
|
//
|
||||||
|
// AUTO-DISCOVERY: At startup and on a periodic refresh, the active fo-blog-v* model
|
||||||
|
// is validated against Ollama's actual model list. If the configured model no longer
|
||||||
|
// exists (e.g. Magatama trained a new version and Ollama removed older tags), the
|
||||||
|
// highest available fo-blog-v* version is picked automatically — no manual env or
|
||||||
|
// settings-file update needed after each training cycle.
|
||||||
|
|
||||||
const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json");
|
const SETTINGS_FILE = join(process.env.TIP_ROOT || "/opt/tip", "blog-llm-settings.json");
|
||||||
|
const STATIC_FALLBACK_MODEL = "fo-blog-v10";
|
||||||
|
const DISCOVERY_REFRESH_MS = Number.parseInt(process.env.BLOG_LLM_DISCOVERY_REFRESH_MS || "", 10) || 10 * 60_000;
|
||||||
|
|
||||||
interface LlmSettings { provider: string; ollamaModel: string }
|
interface LlmSettings { provider: string; ollamaModel: string }
|
||||||
|
|
||||||
function loadSettings(): LlmSettings {
|
function loadSettingsRaw(): LlmSettings {
|
||||||
try {
|
try {
|
||||||
if (existsSync(SETTINGS_FILE)) {
|
if (existsSync(SETTINGS_FILE)) {
|
||||||
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as LlmSettings;
|
const raw = JSON.parse(readFileSync(SETTINGS_FILE, "utf8")) as Partial<LlmSettings>;
|
||||||
return { provider: raw.provider || "ollama", ollamaModel: raw.ollamaModel || "fo-blog-v7" };
|
return {
|
||||||
|
provider: raw.provider || process.env.BLOG_LLM_PROVIDER || "ollama",
|
||||||
|
ollamaModel: raw.ollamaModel || process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch { /* ignore corrupt file */ }
|
} catch { /* ignore corrupt file */ }
|
||||||
return {
|
return {
|
||||||
provider: process.env.BLOG_LLM_PROVIDER || "ollama",
|
provider: process.env.BLOG_LLM_PROVIDER || "ollama",
|
||||||
ollamaModel: process.env.OLLAMA_LLM_MODEL || "fo-blog-v7",
|
ollamaModel: process.env.OLLAMA_LLM_MODEL || STATIC_FALLBACK_MODEL,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let _settings = loadSettings();
|
/**
|
||||||
|
* Sort fo-blog-v{N}[-r{M}] tags newest-first.
|
||||||
|
*
|
||||||
|
* Magatama convention (confirmed by /api/llm/status?lane=fo_blogllm):
|
||||||
|
* - `fo-blog-vN` ← base tag, the active production model after adoption
|
||||||
|
* - `fo-blog-vN-rM` ← revision metadata, intermediate adapter save
|
||||||
|
*
|
||||||
|
* So within the same major N, the BASE tag wins over -rM revisions.
|
||||||
|
*
|
||||||
|
* Order: higher N > lower N; within same N, base ("no -r") > any -rM revision.
|
||||||
|
*/
|
||||||
|
function compareFoBlogVersionsDesc(a: string, b: string): number {
|
||||||
|
const re = /^fo-blog-v(\d+)(?:-r(\d+))?$/;
|
||||||
|
const ma = re.exec(a);
|
||||||
|
const mb = re.exec(b);
|
||||||
|
if (!ma || !mb) return a.localeCompare(b);
|
||||||
|
const va = Number.parseInt(ma[1], 10);
|
||||||
|
const vb = Number.parseInt(mb[1], 10);
|
||||||
|
if (va !== vb) return vb - va;
|
||||||
|
// Same major version: base tag (no -r suffix) wins over any -rM revision
|
||||||
|
const aIsBase = ma[2] === undefined;
|
||||||
|
const bIsBase = mb[2] === undefined;
|
||||||
|
if (aIsBase !== bIsBase) return aIsBase ? -1 : 1;
|
||||||
|
// Both have -rM: higher M is newer
|
||||||
|
const ra = ma[2] ? Number.parseInt(ma[2], 10) : 0;
|
||||||
|
const rb = mb[2] ? Number.parseInt(mb[2], 10) : 0;
|
||||||
|
return rb - ra;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OllamaTag { name: string }
|
||||||
|
interface OllamaTagsResponse { models: OllamaTag[] }
|
||||||
|
|
||||||
|
/** Probe Ollama for available fo-blog-v* models. Returns [] on any error (non-fatal). */
|
||||||
|
async function fetchOllamaFoBlogTags(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
||||||
|
if (!resp.ok) return [];
|
||||||
|
const data = await resp.json() as OllamaTagsResponse;
|
||||||
|
return (data.models || [])
|
||||||
|
.map(m => m.name.replace(/:latest$/, ""))
|
||||||
|
.filter(n => /^fo-blog-v\d+(?:-r\d+)?$/.test(n));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile configured model against Ollama reality.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. Configured model (env or settings file) — if Ollama actually serves it
|
||||||
|
* 2. Highest fo-blog-v* version Ollama actually serves — auto-discovered
|
||||||
|
* 3. Static fallback STATIC_FALLBACK_MODEL — last resort
|
||||||
|
*
|
||||||
|
* Non-blocking: any Ollama failure leaves _settings untouched.
|
||||||
|
*/
|
||||||
|
async function reconcileWithOllama(): Promise<void> {
|
||||||
|
const configured = _settings.ollamaModel;
|
||||||
|
if (!configured.startsWith("fo-blog-v")) return; // only manage fo-blog-* lane
|
||||||
|
const available = await fetchOllamaFoBlogTags();
|
||||||
|
if (available.length === 0) return;
|
||||||
|
if (available.includes(configured)) return; // configured model still exists
|
||||||
|
|
||||||
|
const sorted = [...available].sort(compareFoBlogVersionsDesc);
|
||||||
|
const winner = sorted[0];
|
||||||
|
if (!winner || winner === configured) return;
|
||||||
|
|
||||||
|
console.log(`[LLM] auto-discovery: configured "${configured}" not in Ollama; switching to latest available "${winner}" (candidates: ${sorted.join(", ")})`);
|
||||||
|
_settings = { ..._settings, ollamaModel: winner };
|
||||||
|
try { writeFileSync(SETTINGS_FILE, JSON.stringify(_settings, null, 2), "utf8"); } catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
let _settings = loadSettingsRaw();
|
||||||
|
|
||||||
|
// Fire-and-forget initial reconciliation. Subsequent refresh runs every DISCOVERY_REFRESH_MS.
|
||||||
|
void reconcileWithOllama();
|
||||||
|
setInterval(() => { void reconcileWithOllama(); }, DISCOVERY_REFRESH_MS).unref();
|
||||||
|
|
||||||
/** Switch the active LLM provider at runtime. Persists to settings file. */
|
/** Switch the active LLM provider at runtime. Persists to settings file. */
|
||||||
export function setLlmProvider(provider: string, ollamaModel?: string): void {
|
export function setLlmProvider(provider: string, ollamaModel?: string): void {
|
||||||
@ -52,6 +139,15 @@ export function setLlmProvider(provider: string, ollamaModel?: string): void {
|
|||||||
/** Returns the currently active provider config. */
|
/** Returns the currently active provider config. */
|
||||||
export function getLlmProvider(): LlmSettings { return { ..._settings }; }
|
export function getLlmProvider(): LlmSettings { return { ..._settings }; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force an immediate auto-discovery reconciliation against Ollama.
|
||||||
|
* Returns the active settings after reconcile.
|
||||||
|
*/
|
||||||
|
export async function refreshLlmAutoDiscovery(): Promise<LlmSettings> {
|
||||||
|
await reconcileWithOllama();
|
||||||
|
return { ..._settings };
|
||||||
|
}
|
||||||
|
|
||||||
// Convenience getters used below (re-read on every call for zero-latency switch)
|
// Convenience getters used below (re-read on every call for zero-latency switch)
|
||||||
function provider(): string { return _settings.provider; }
|
function provider(): string { return _settings.provider; }
|
||||||
function llmModel(): string { return _settings.ollamaModel; }
|
function llmModel(): string { return _settings.ollamaModel; }
|
||||||
|
|||||||
@ -165,6 +165,242 @@ hypeCycleRouter.get("/regional/:tech", (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── MARKET SIGNAL COMPUTATION ───────────────────────────────────────────────
|
||||||
|
/** Technology → form-factor/speed mapping for cross-table signal aggregation */
|
||||||
|
const TECH_SIGNAL_MAP = [
|
||||||
|
{ label: "10G-SFP+", speedGbps: 10, formFactors: ["SFP+"], speedLabel: "10G" },
|
||||||
|
{ label: "100G-QSFP28", speedGbps: 100, formFactors: ["QSFP28"], speedLabel: "100G" },
|
||||||
|
{ label: "400G-QSFP-DD", speedGbps: 400, formFactors: ["QSFP-DD"], speedLabel: "400G" },
|
||||||
|
{ label: "800G-OSFP", speedGbps: 800, formFactors: ["OSFP"], speedLabel: "800G" },
|
||||||
|
{ label: "1.6T-OSFP", speedGbps: 1600, formFactors: ["OSFP"], speedLabel: "1.6T" },
|
||||||
|
{ label: "400G-ZR", speedGbps: 400, formFactors: ["SFP-DD", "CFP2"], speedLabel: "400G" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PhaseKey =
|
||||||
|
| "plateau_productivity" | "slope_enlightenment" | "trough_disillusionment"
|
||||||
|
| "peak_inflated_expectations" | "innovation_trigger";
|
||||||
|
|
||||||
|
function buildRecommendation(
|
||||||
|
phase: PhaseKey,
|
||||||
|
signalScore: number,
|
||||||
|
capexYoyAvg: number,
|
||||||
|
speedGbps: number,
|
||||||
|
): { label: string; color: string; detail: string } {
|
||||||
|
const fast = speedGbps >= 400;
|
||||||
|
const capexBoom = capexYoyAvg > 50;
|
||||||
|
|
||||||
|
switch (phase) {
|
||||||
|
case "plateau_productivity":
|
||||||
|
if (fast && capexBoom)
|
||||||
|
return { label: "🚀 Buy — AI Wave", color: "#16a34a", detail: "Commodity pricing + AI infrastructure demand surge. Stock up now." };
|
||||||
|
if (signalScore >= 85)
|
||||||
|
return { label: "✅ Hold — Stable", color: "#2563eb", detail: "Mature commodity market, stable long-term demand." };
|
||||||
|
return { label: "📦 Hold", color: "#64748b", detail: "Commodity market. Price-driven. Order on demand." };
|
||||||
|
case "slope_enlightenment":
|
||||||
|
if (capexBoom)
|
||||||
|
return { label: "🟢 Buy Now", color: "#16a34a", detail: "Growing mainstream adoption + hyperscaler capex boom. Window closing." };
|
||||||
|
return { label: "🟡 Buy", color: "#ca8a04", detail: "Adoption curve steepening. Monitor pricing before large orders." };
|
||||||
|
case "trough_disillusionment":
|
||||||
|
if (signalScore > 50)
|
||||||
|
return { label: "🔍 Buy Opportunity", color: "#7c3aed", detail: "Hype trough but demand signals emerging. Strategic buying window." };
|
||||||
|
return { label: "⏳ Watch", color: "#94a3b8", detail: "Wait for demand confirmation before stocking." };
|
||||||
|
case "peak_inflated_expectations":
|
||||||
|
if (fast && capexBoom)
|
||||||
|
return { label: "⚡ Caution / Buy", color: "#f97316", detail: "Hype peak but real hyperscaler demand. Buy selectively, not speculatively." };
|
||||||
|
return { label: "⚠ Caution", color: "#ef4444", detail: "Peak hype. Verify real end-customer demand before building inventory." };
|
||||||
|
case "innovation_trigger":
|
||||||
|
return { label: "👁 Watch", color: "#94a3b8", detail: "Early stage — too early for volume commitments. Monitor for traction." };
|
||||||
|
default:
|
||||||
|
return { label: "📊 Monitor", color: "#64748b", detail: "Monitor signals." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/hype-cycle/market-signals — Multi-source demand intelligence
|
||||||
|
hypeCycleRouter.get("/market-signals", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { pool } = await import("../db/client");
|
||||||
|
|
||||||
|
// ── Parallel data fetch ──────────────────────────────────────────────────
|
||||||
|
const [hypeRows, priceRows, capexRows, aiRows, ebayRows, internalRows] = await Promise.all([
|
||||||
|
// Latest hype cycle per technology
|
||||||
|
pool.query(`
|
||||||
|
SELECT DISTINCT ON (technology)
|
||||||
|
technology, hype_phase, hype_score, asp_current_usd,
|
||||||
|
r_squared, computed_at, current_share, years_to_next_phase
|
||||||
|
FROM hype_cycle_analysis
|
||||||
|
ORDER BY technology, computed_at DESC
|
||||||
|
`),
|
||||||
|
|
||||||
|
// Price observation activity: last 30d vs prior 30d (scraping frequency = demand proxy)
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
t.speed_gbps,
|
||||||
|
t.form_factor,
|
||||||
|
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '30 days') AS obs_30d,
|
||||||
|
COUNT(*) FILTER (WHERE po.time >= NOW() - INTERVAL '60 days'
|
||||||
|
AND po.time < NOW() - INTERVAL '30 days') AS obs_prior_30d
|
||||||
|
FROM price_observations po
|
||||||
|
JOIN transceivers t ON t.id = po.transceiver_id
|
||||||
|
WHERE po.time >= NOW() - INTERVAL '60 days'
|
||||||
|
GROUP BY t.speed_gbps, t.form_factor
|
||||||
|
`),
|
||||||
|
|
||||||
|
// Hyperscaler capex — most recent per company
|
||||||
|
pool.query(`
|
||||||
|
SELECT DISTINCT ON (company)
|
||||||
|
company, period_label, capex_usd_millions,
|
||||||
|
dc_capex_est_millions, yoy_growth_pct
|
||||||
|
FROM hyperscaler_capex
|
||||||
|
WHERE yoy_growth_pct IS NOT NULL
|
||||||
|
ORDER BY company, period_end DESC
|
||||||
|
`),
|
||||||
|
|
||||||
|
// AI cluster demand last 90 days
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(estimated_transceivers), 0) AS total_tx,
|
||||||
|
COUNT(*) AS cluster_count,
|
||||||
|
COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates
|
||||||
|
FROM ai_cluster_announcements
|
||||||
|
WHERE announced_date >= NOW() - INTERVAL '90 days'
|
||||||
|
`),
|
||||||
|
|
||||||
|
// eBay marketplace velocity
|
||||||
|
pool.query(`
|
||||||
|
SELECT DISTINCT ON (form_factor, speed_label)
|
||||||
|
marketplace, keyword, form_factor, speed_label,
|
||||||
|
sold_count_30d, active_listings, avg_sold_price
|
||||||
|
FROM marketplace_velocity
|
||||||
|
WHERE sold_count_30d IS NOT NULL
|
||||||
|
ORDER BY form_factor, speed_label, scraped_at DESC
|
||||||
|
`),
|
||||||
|
|
||||||
|
// Internal demand: fast-mover trend
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE velocity_class = 'fast_mover' AND demand_trend_pct > 0) AS fast_mover_pos,
|
||||||
|
COUNT(*) FILTER (WHERE velocity_class = 'fast_mover' AND demand_trend_pct < 0) AS fast_mover_neg,
|
||||||
|
AVG(demand_trend_pct) FILTER (WHERE velocity_class = 'fast_mover') AS avg_fast_trend,
|
||||||
|
AVG(demand_trend_pct) FILTER (WHERE velocity_class = 'regular') AS avg_regular_trend
|
||||||
|
FROM flexoptix_internal_demand
|
||||||
|
WHERE is_internal = true
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Pre-compute global signals ───────────────────────────────────────────
|
||||||
|
const capexYoyValues = capexRows.rows
|
||||||
|
.map((r) => parseFloat(r.yoy_growth_pct))
|
||||||
|
.filter((v) => !isNaN(v));
|
||||||
|
const capexYoyAvg = capexYoyValues.length
|
||||||
|
? capexYoyValues.reduce((a, b) => a + b, 0) / capexYoyValues.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const aiData = aiRows.rows[0];
|
||||||
|
const totalAiTx = parseInt(aiData?.total_tx ?? "0") || 0;
|
||||||
|
|
||||||
|
const internalData = internalRows.rows[0];
|
||||||
|
const avgFastTrend = parseFloat(internalData?.avg_fast_trend ?? "0") || 0;
|
||||||
|
|
||||||
|
// Build price activity map: speedGbps+formFactor → ratio (30d/prior30d)
|
||||||
|
const priceMap = new Map<string, number>();
|
||||||
|
for (const row of priceRows.rows) {
|
||||||
|
const obs30 = parseInt(row.obs_30d) || 0;
|
||||||
|
const obsPrior = parseInt(row.obs_prior_30d) || 1;
|
||||||
|
const key = `${row.speed_gbps}__${row.form_factor ?? ""}`;
|
||||||
|
priceMap.set(key, obs30 / obsPrior);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build eBay map: speedLabel → sold_count_30d
|
||||||
|
const ebayMap = new Map<string, number>();
|
||||||
|
for (const row of ebayRows.rows) {
|
||||||
|
const speed = (row.speed_label ?? "").replace(/\s+/g, "");
|
||||||
|
ebayMap.set(speed, parseInt(row.sold_count_30d) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-technology signals ───────────────────────────────────────────────
|
||||||
|
const technologies = hypeRows.rows.map((r) => {
|
||||||
|
const tech = TECH_SIGNAL_MAP.find((t) => t.label === r.technology);
|
||||||
|
const phase = (r.hype_phase ?? "innovation_trigger") as PhaseKey;
|
||||||
|
const hypeScore = parseInt(r.hype_score) || 0;
|
||||||
|
|
||||||
|
// Price activity ratio: average across matching speed+formFactor combos
|
||||||
|
let priceRatios: number[] = [];
|
||||||
|
if (tech) {
|
||||||
|
for (const ff of tech.formFactors) {
|
||||||
|
const key = `${tech.speedGbps}__${ff}`;
|
||||||
|
const ratio = priceMap.get(key);
|
||||||
|
if (ratio !== undefined) priceRatios.push(ratio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const priceActivityRatio = priceRatios.length
|
||||||
|
? priceRatios.reduce((a, b) => a + b, 0) / priceRatios.length
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
// eBay velocity for this speed
|
||||||
|
const ebayVelocity = tech ? (ebayMap.get(tech.speedLabel) ?? 0) : 0;
|
||||||
|
|
||||||
|
// AI cluster: allocate demand proportionally for high-speed techs
|
||||||
|
const aiBoostTx = tech && tech.speedGbps >= 400 ? totalAiTx : 0;
|
||||||
|
|
||||||
|
// Compute composite score (0–100)
|
||||||
|
let score = hypeScore * 0.3;
|
||||||
|
const capexBoostPts = capexYoyAvg > 100 ? 18 : capexYoyAvg > 50 ? 12 : capexYoyAvg > 20 ? 5 : 0;
|
||||||
|
const priceBoostPts = priceActivityRatio > 1.3 ? 10 : priceActivityRatio > 1.0 ? 5 : -3;
|
||||||
|
const aiBoostPts = aiBoostTx > 100000 ? 14 : aiBoostTx > 50000 ? 9 : aiBoostTx > 10000 ? 4 : 0;
|
||||||
|
const ebayBoostPts = ebayVelocity > 200 ? 8 : ebayVelocity > 100 ? 5 : ebayVelocity > 50 ? 2 : 0;
|
||||||
|
const intlBoostPts = avgFastTrend > 10 ? 6 : avgFastTrend > 0 ? 3 : avgFastTrend < -20 ? -5 : 0;
|
||||||
|
|
||||||
|
score += capexBoostPts + priceBoostPts + aiBoostPts + ebayBoostPts + intlBoostPts;
|
||||||
|
const marketSignalScore = Math.max(0, Math.min(100, Math.round(score)));
|
||||||
|
|
||||||
|
const rec = buildRecommendation(phase, marketSignalScore, capexYoyAvg, tech?.speedGbps ?? 0);
|
||||||
|
|
||||||
|
// Signal drivers list for tooltip
|
||||||
|
const drivers: string[] = [];
|
||||||
|
if (capexBoostPts > 0) drivers.push(`Hyperscaler CapEx +${capexYoyAvg.toFixed(0)}% YoY avg`);
|
||||||
|
if (priceActivityRatio > 1.1) drivers.push(`Price obs +${((priceActivityRatio - 1) * 100).toFixed(0)}% MoM activity`);
|
||||||
|
if (aiBoostTx > 0) drivers.push(`~${(aiBoostTx / 1000).toFixed(0)}k transceivers in AI cluster builds`);
|
||||||
|
if (ebayVelocity > 0) drivers.push(`${ebayVelocity} units sold on secondary market (30d)`);
|
||||||
|
if (intlBoostPts > 0) drivers.push(`Internal fast-movers trending ${avgFastTrend > 0 ? "+" : ""}${avgFastTrend.toFixed(1)}%`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
technology: r.technology,
|
||||||
|
phase,
|
||||||
|
hypeScore,
|
||||||
|
aspCurrentUsd: r.asp_current_usd,
|
||||||
|
marketSignalScore,
|
||||||
|
recommendation: rec,
|
||||||
|
drivers,
|
||||||
|
speedGbps: tech?.speedGbps,
|
||||||
|
priceActivityRatio: Math.round(priceActivityRatio * 100) / 100,
|
||||||
|
ebayVelocity,
|
||||||
|
computedAt: r.computed_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Global context ───────────────────────────────────────────────────────
|
||||||
|
const globalContext = {
|
||||||
|
hyperscalerCapex: capexRows.rows.map((r) => ({
|
||||||
|
company: r.company,
|
||||||
|
periodLabel: r.period_label,
|
||||||
|
capexMillions: parseFloat(r.capex_usd_millions),
|
||||||
|
dcCapexMillions: parseFloat(r.dc_capex_est_millions),
|
||||||
|
yoyGrowthPct: parseFloat(r.yoy_growth_pct),
|
||||||
|
})),
|
||||||
|
capexYoyAvg: Math.round(capexYoyAvg),
|
||||||
|
capexBoom: capexYoyAvg > 50,
|
||||||
|
totalAiClusterTx90d: totalAiTx,
|
||||||
|
aiClusterCount90d: parseInt(aiData?.cluster_count ?? "0") || 0,
|
||||||
|
internalFastMoverTrend: Math.round(avgFastTrend * 10) / 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({ success: true, technologies, globalContext, computed_at: new Date().toISOString() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Market signals error:", err);
|
||||||
|
res.status(500).json({ success: false, error: "Failed to compute market signals" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// GET /api/hype-cycle/analysis — Bass-fitted results from DB (hype_cycle_analysis table)
|
// GET /api/hype-cycle/analysis — Bass-fitted results from DB (hype_cycle_analysis table)
|
||||||
hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
|
hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -424,3 +424,79 @@ procurementRouter.get("/internal-demand", async (req: Request, res: Response) =>
|
|||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// GET /api/procurement/hyperscaler-capex
|
||||||
|
// Hyperscaler quarterly CapEx from SEC filings — demand context for transceivers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
procurementRouter.get("/hyperscaler-capex", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
company, period_label, period_end,
|
||||||
|
capex_usd_millions, dc_capex_est_millions,
|
||||||
|
yoy_growth_pct, filing_type, source_url
|
||||||
|
FROM hyperscaler_capex
|
||||||
|
ORDER BY period_end DESC, capex_usd_millions DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const summaryResult = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
company,
|
||||||
|
MAX(period_end) AS latest_period_end,
|
||||||
|
MAX(period_label) AS latest_period,
|
||||||
|
(ARRAY_AGG(capex_usd_millions ORDER BY period_end DESC))[1] AS latest_capex,
|
||||||
|
(ARRAY_AGG(dc_capex_est_millions ORDER BY period_end DESC))[1] AS latest_dc_capex,
|
||||||
|
(ARRAY_AGG(yoy_growth_pct ORDER BY period_end DESC))[1] AS latest_yoy_growth,
|
||||||
|
AVG(yoy_growth_pct) FILTER (WHERE period_end >= NOW() - INTERVAL '365 days') AS avg_yoy_12m
|
||||||
|
FROM hyperscaler_capex
|
||||||
|
GROUP BY company
|
||||||
|
ORDER BY latest_capex DESC NULLS LAST
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: result.rows,
|
||||||
|
summary: summaryResult.rows,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Hyperscaler capex error:", err);
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// GET /api/procurement/marketplace-velocity
|
||||||
|
// Secondary market (eBay) sell-through as demand signal
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
procurementRouter.get("/marketplace-velocity", async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT DISTINCT ON (form_factor, speed_label)
|
||||||
|
marketplace, keyword, form_factor, speed_label,
|
||||||
|
sold_count_30d, active_listings, avg_sold_price,
|
||||||
|
min_price, max_price, currency, scraped_at
|
||||||
|
FROM marketplace_velocity
|
||||||
|
ORDER BY form_factor, speed_label, scraped_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
const hotResult = await pool.query(`
|
||||||
|
SELECT DISTINCT ON (form_factor, speed_label)
|
||||||
|
marketplace, form_factor, speed_label,
|
||||||
|
sold_count_30d, active_listings, avg_sold_price
|
||||||
|
FROM marketplace_velocity
|
||||||
|
WHERE sold_count_30d > 0
|
||||||
|
ORDER BY form_factor, speed_label, scraped_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: result.rows,
|
||||||
|
hot: hotResult.rows.sort(
|
||||||
|
(a: { sold_count_30d: string }, b: { sold_count_30d: string }) =>
|
||||||
|
parseInt(b.sold_count_30d) - parseInt(a.sold_count_30d)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Marketplace velocity error:", err);
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -31,13 +31,28 @@
|
|||||||
}
|
}
|
||||||
blogPipelineRunning = true;
|
blogPipelineRunning = true;
|
||||||
|
|
||||||
|
// Fetch the current active model name so we never show a stale hardcoded version.
|
||||||
|
var initialModelLabel = (window._activeFoBlogModel) || 'FO_BlogLLM';
|
||||||
|
fetch(API + '/api/blog/llm/status', { headers: authHeaders() })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
var m = data && data.llm && data.llm.model;
|
||||||
|
if (m) {
|
||||||
|
window._activeFoBlogModel = m;
|
||||||
|
var s = document.getElementById('bp-step');
|
||||||
|
if (s && s.textContent.indexOf('Connecting to FO_BlogLLM') === 0) {
|
||||||
|
s.textContent = 'Connecting to FO_BlogLLM (' + m + ')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(function() {});
|
||||||
|
|
||||||
var pipelineEl = document.getElementById('blog-pipeline-status');
|
var pipelineEl = document.getElementById('blog-pipeline-status');
|
||||||
if (pipelineEl) {
|
if (pipelineEl) {
|
||||||
pipelineEl.innerHTML =
|
pipelineEl.innerHTML =
|
||||||
'<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' +
|
'<div style="background:linear-gradient(135deg,#1a1a1a,#2a2a2a);color:white;padding:2rem;border-radius:12px;text-align:center;margin-bottom:1rem">' +
|
||||||
'<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' +
|
'<div style="font-size:1.4rem;font-weight:700;margin-bottom:1rem">Generating Blog with AI...</div>' +
|
||||||
'<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' +
|
'<div id="bp-status" style="font-size:1rem;color:#FF8100;margin-bottom:0.5rem">Starting 10-step Flexoptix Style pipeline...</div>' +
|
||||||
'<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to FO_BlogLLM (fo-blog-v7)</div>' +
|
'<div id="bp-step" style="font-size:0.85rem;color:#aaa">Connecting to ' + initialModelLabel + '</div>' +
|
||||||
'<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' +
|
'<div style="margin-top:1.5rem;background:#333;border-radius:8px;height:8px;overflow:hidden">' +
|
||||||
'<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' +
|
'<div id="bp-bar" style="width:2%;height:100%;background:#FF8100;transition:width 0.5s ease"></div></div>' +
|
||||||
'<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' +
|
'<div id="bp-pct" style="font-size:0.8rem;color:#666;margin-top:0.5rem">0%</div>' +
|
||||||
@ -137,7 +152,7 @@
|
|||||||
if (bar) bar.style.width = prog.pct + '%';
|
if (bar) bar.style.width = prog.pct + '%';
|
||||||
if (pct) pct.textContent = prog.pct + '%';
|
if (pct) pct.textContent = prog.pct + '%';
|
||||||
if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); }
|
if (status) { status.style.color = '#FF8100'; status.textContent = prog.label || ('Step ' + prog.step + '/10'); }
|
||||||
if (step) step.textContent = 'Step ' + prog.step + '/10 · fo-blog-v7 via adapter bridge';
|
if (step) step.textContent = 'Step ' + prog.step + '/10 · ' + (window._activeFoBlogModel || 'fo-blog-v10') + ' via adapter bridge';
|
||||||
} else {
|
} else {
|
||||||
_stallCount++;
|
_stallCount++;
|
||||||
// After 5 consecutive non-running polls (~40s), show stall warning
|
// After 5 consecutive non-running polls (~40s), show stall warning
|
||||||
|
|||||||
@ -916,6 +916,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="hype-svg-container"></div>
|
<div id="hype-svg-container"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Market Context cards — loaded dynamically from market-signals API -->
|
||||||
|
<div id="hype-market-context" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));margin-bottom:1.25rem">
|
||||||
|
<div class="loading pulse" style="grid-column:1/-1;padding:0.75rem">Loading market signals…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mt">
|
<div class="card mt">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@ -923,9 +928,8 @@
|
|||||||
<th>Technology<span class="sort-arrow"></span></th>
|
<th>Technology<span class="sort-arrow"></span></th>
|
||||||
<th class="tip" data-tip="Current phase in the technology adoption lifecycle.">Phase<span class="sort-arrow"></span></th>
|
<th class="tip" data-tip="Current phase in the technology adoption lifecycle.">Phase<span class="sort-arrow"></span></th>
|
||||||
<th class="tip" data-tip="Position on the hype curve (0-100%).">Position<span class="sort-arrow"></span></th>
|
<th class="tip" data-tip="Position on the hype curve (0-100%).">Position<span class="sort-arrow"></span></th>
|
||||||
<th class="tip" data-tip="Cumulative market adoption based on Norton-Bass diffusion model. 0-100% of total addressable market. MODELL — keine echten Marktdaten.">Adoption <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th>
|
<th class="tip" data-tip="Composite Market Signal Score (0–100). Blends hype score + hyperscaler capex trend + price observation activity + AI cluster demand + eBay secondary market + internal demand velocity. Higher = stronger real-world demand signal.">Market Signal <span style="font-size:0.58rem;color:#16a34a;font-weight:600">● LIVE</span><span class="sort-arrow"></span></th>
|
||||||
<th class="tip" data-tip="Estimated year of peak hype — Norton-Bass model estimate, not real forecast data.">Peak <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th>
|
<th class="tip" data-tip="Data-driven buy recommendation based on hype phase + multi-source market signals.">Recommendation<span class="sort-arrow"></span></th>
|
||||||
<th class="tip" data-tip="Years until mainstream — Norton-Bass model estimate.">To Plateau <span style="font-size:0.58rem;color:#6366f1;font-weight:600">[M]</span><span class="sort-arrow"></span></th>
|
|
||||||
<th class="tip" data-tip="Current OEM ASP in USD — from Mouser/market data.">OEM ASP<span class="sort-arrow"></span></th>
|
<th class="tip" data-tip="Current OEM ASP in USD — from Mouser/market data.">OEM ASP<span class="sort-arrow"></span></th>
|
||||||
<th class="tip" data-tip="Bass model goodness-of-fit (R²). Higher = more reliable forecast.">R²<span class="sort-arrow"></span></th>
|
<th class="tip" data-tip="Bass model goodness-of-fit (R²). Higher = more reliable forecast.">R²<span class="sort-arrow"></span></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
@ -934,7 +938,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim);display:flex;gap:1.5rem;flex-wrap:wrap">
|
<div style="margin-top:0.5rem;font-size:0.7rem;color:var(--text-dim);display:flex;gap:1.5rem;flex-wrap:wrap">
|
||||||
<span><span style="color:#6366f1;font-weight:700">[M]</span> = MODELL — Norton-Bass mathematische Schätzung, keine echten Marktdaten</span>
|
<span><span style="color:#16a34a;font-weight:700">● LIVE</span> = Real market data: hyperscaler capex + price activity + AI cluster demand + eBay velocity + internal demand</span>
|
||||||
<span>OEM ASP = real (Mouser/Marktdaten)</span>
|
<span>OEM ASP = real (Mouser/Marktdaten)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card mt" style="border-left:3px solid var(--cyan)">
|
<div class="card mt" style="border-left:3px solid var(--cyan)">
|
||||||
@ -974,6 +978,31 @@
|
|||||||
<div class="loading pulse" style="padding:2.5rem;text-align:center">Loading sourcing data…</div>
|
<div class="loading pulse" style="padding:2.5rem;text-align:center">Loading sourcing data…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hyperscaler CapEx panel -->
|
||||||
|
<div class="card mt" style="border-left:3px solid #2563eb">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">Hyperscaler CapEx <span style="font-size:0.72rem;font-weight:700;background:#2563eb22;color:#2563eb;border:1px solid #2563eb44;border-radius:3px;padding:1px 6px">SEC Filings</span></div>
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Quarterly infrastructure spend — primary demand driver for high-speed transceivers</div>
|
||||||
|
</div>
|
||||||
|
<div id="capex-avg-badge" style="font-size:0.82rem;font-weight:700;padding:4px 12px;background:#16a34a11;border:1px solid #16a34a33;border-radius:6px;color:#16a34a"></div>
|
||||||
|
</div>
|
||||||
|
<div id="capex-table-container">
|
||||||
|
<div class="loading pulse">Loading capex data…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- eBay Marketplace panel -->
|
||||||
|
<div class="card mt" style="border-left:3px solid #f97316">
|
||||||
|
<div style="margin-bottom:0.75rem">
|
||||||
|
<div style="font-size:1rem;font-weight:700;color:var(--text-bright)">eBay Secondary Market <span style="font-size:0.72rem;font-weight:700;background:#f9731622;color:#f97316;border:1px solid #f9731644;border-radius:3px;padding:1px 6px">Demand Signal</span></div>
|
||||||
|
<div style="font-size:0.72rem;color:var(--text-dim);margin-top:2px">Secondary market sell-through rates — real end-buyer demand by form factor</div>
|
||||||
|
</div>
|
||||||
|
<div id="ebay-velocity-container">
|
||||||
|
<div class="loading pulse">Loading marketplace data…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -1568,6 +1597,7 @@
|
|||||||
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
|
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</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('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('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>
|
||||||
<button onclick="showProcSection('market')" id="proc-btn-market" class="proc-btn">Market Intelligence</button>
|
<button onclick="showProcSection('market')" id="proc-btn-market" class="proc-btn">Market Intelligence</button>
|
||||||
<button onclick="showProcSection('lifecycle')" id="proc-btn-lifecycle" class="proc-btn">Lifecycle Events</button>
|
<button onclick="showProcSection('lifecycle')" id="proc-btn-lifecycle" class="proc-btn">Lifecycle Events</button>
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
@ -1658,6 +1688,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- eBay Marketplace section -->
|
||||||
|
<div id="proc-section-marketplace" style="display:none">
|
||||||
|
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;align-items:center;flex-wrap:wrap">
|
||||||
|
<span style="font-size:0.8rem;font-weight:700;color:var(--text)">Secondary Market Demand (eBay)</span>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<span style="font-size:0.72rem;color:var(--text-dim)">Higher sell-through = real end-buyer demand, price floor signal</span>
|
||||||
|
</div>
|
||||||
|
<div id="proc-marketplace-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">
|
||||||
|
<div class="loading pulse">Loading marketplace data...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- AI Clusters section -->
|
<!-- AI Clusters section -->
|
||||||
<div id="proc-section-ai-clusters" style="display:none">
|
<div id="proc-section-ai-clusters" style="display:none">
|
||||||
<div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="ai-cluster-stats">
|
<div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="ai-cluster-stats">
|
||||||
@ -3336,17 +3378,42 @@ async function loadHypeCycle() {
|
|||||||
dot.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); });
|
dot.addEventListener('click', function() { openHypeDetail(this.getAttribute('data-tech')); });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load market signals in parallel and enrich the table
|
||||||
|
var marketSignals = {};
|
||||||
|
var globalCtx = {};
|
||||||
|
try {
|
||||||
|
var msData = await api('/api/hype-cycle/market-signals');
|
||||||
|
if (msData.success && msData.technologies) {
|
||||||
|
msData.technologies.forEach(function(ms) { marketSignals[ms.technology] = ms; });
|
||||||
|
globalCtx = msData.globalContext || {};
|
||||||
|
renderHypeMarketContext(globalCtx, msData.technologies);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
var ctxEl = el('hype-market-context');
|
||||||
|
if (ctxEl) ctxEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
buildDOM(el('hype-table'), techs.map(function(t) {
|
buildDOM(el('hype-table'), techs.map(function(t) {
|
||||||
var color = PC[t.phase] || '#8888a4';
|
var color = PC[t.phase] || '#8888a4';
|
||||||
// FIX: adoptionPct is already a percentage (0-100), do NOT multiply by 100
|
var ms = marketSignals[t.technology];
|
||||||
var adoptionDisplay = (t.adoptionPct != null ? t.adoptionPct : 0) + '%';
|
var signalHtml = '—';
|
||||||
|
var recHtml = '—';
|
||||||
|
if (ms) {
|
||||||
|
var sc = ms.marketSignalScore;
|
||||||
|
var scColor = sc >= 70 ? '#16a34a' : sc >= 50 ? '#ca8a04' : sc >= 30 ? '#f97316' : '#94a3b8';
|
||||||
|
var driversTip = ms.drivers && ms.drivers.length ? ms.drivers.join(' · ') : 'No signal data';
|
||||||
|
signalHtml = '<span class="tip" data-tip="' + esc(driversTip) + '" style="font-weight:700;color:' + scColor + ';font-family:monospace">' + sc + '</span>'
|
||||||
|
+ '<div style="width:60px;height:4px;background:var(--surface2);border-radius:2px;margin-top:3px;display:inline-block;vertical-align:middle;margin-left:6px"><div style="width:' + sc + '%;height:100%;background:' + scColor + ';border-radius:2px"></div></div>';
|
||||||
|
if (ms.recommendation) {
|
||||||
|
recHtml = '<span class="tip" data-tip="' + esc(ms.recommendation.detail) + '" style="font-size:0.72rem;font-weight:700;white-space:nowrap;color:' + ms.recommendation.color + '">' + esc(ms.recommendation.label) + '</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
return '<tr class="clickable" data-tech="' + esc(t.technology) + '">'
|
return '<tr class="clickable" data-tech="' + esc(t.technology) + '">'
|
||||||
+ '<td style="font-weight:600;color:var(--text-bright)">' + esc(t.technology) + '</td>'
|
+ '<td style="font-weight:600;color:var(--text-bright)">' + esc(t.technology) + '</td>'
|
||||||
+ '<td><span class="b tip" data-tip="' + esc(PHASE_DESC[t.phase] || '') + '" style="background:' + color + '18;color:' + color + ';border:1px solid ' + color + '33">' + esc(t.phase) + '</span></td>'
|
+ '<td><span class="b tip" data-tip="' + esc(PHASE_DESC[t.phase] || '') + '" style="background:' + color + '18;color:' + color + ';border:1px solid ' + color + '33">' + esc(t.phase) + '</span></td>'
|
||||||
+ '<td><div class="hype-bar"><div class="hype-fill" style="width:' + t.positionPct + '%;background:' + color + '"></div></div></td>'
|
+ '<td><div class="hype-bar"><div class="hype-fill" style="width:' + t.positionPct + '%;background:' + color + '"></div></div></td>'
|
||||||
+ '<td class="mono tip" data-tip="' + esc(HYPE_TIPS['Adoption']) + '">' + adoptionDisplay + '</td>'
|
+ '<td>' + signalHtml + '</td>'
|
||||||
+ '<td class="mono">' + esc(t.peakYear || '—') + '</td>'
|
+ '<td>' + recHtml + '</td>'
|
||||||
+ '<td class="mono">' + (t.yearsToPlateauFromNow != null ? t.yearsToPlateauFromNow + 'y' : '—') + '</td>'
|
|
||||||
+ '<td class="mono">' + (t.aspCurrentUsd != null ? '$' + Number(t.aspCurrentUsd).toLocaleString() : '—') + '</td>'
|
+ '<td class="mono">' + (t.aspCurrentUsd != null ? '$' + Number(t.aspCurrentUsd).toLocaleString() : '—') + '</td>'
|
||||||
+ '<td class="mono">' + (t.rSquared != null ? Number(t.rSquared).toFixed(2) : '—') + '</td>'
|
+ '<td class="mono">' + (t.rSquared != null ? Number(t.rSquared).toFixed(2) : '—') + '</td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
@ -3369,6 +3436,9 @@ async function loadHypeCycle() {
|
|||||||
|
|
||||||
// Render Sourcing Hype Cycle
|
// Render Sourcing Hype Cycle
|
||||||
loadSourcingHypeCycle();
|
loadSourcingHypeCycle();
|
||||||
|
|
||||||
|
// Render Hyperscaler CapEx + eBay panels
|
||||||
|
loadHypeCapexAndEbay();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SOURCING HYPE CYCLE ──────────────────────────────────────────────────────
|
// ── SOURCING HYPE CYCLE ──────────────────────────────────────────────────────
|
||||||
@ -3395,7 +3465,8 @@ async function loadSourcingHypeCycle() {
|
|||||||
var meta = document.getElementById('sourcing-hype-meta');
|
var meta = document.getElementById('sourcing-hype-meta');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
var seedData = [
|
// Static fallback (only used if API fails)
|
||||||
|
var seedFallback = [
|
||||||
{ label: 'SFP+ 10G', speed_gbps: 10, form_factor: 'SFP+', obs: 1402, avg_price: 94.86 },
|
{ label: 'SFP+ 10G', speed_gbps: 10, form_factor: 'SFP+', obs: 1402, avg_price: 94.86 },
|
||||||
{ label: 'SFP 1G', speed_gbps: 1, form_factor: 'SFP', obs: 1103, avg_price: 35.11 },
|
{ label: 'SFP 1G', speed_gbps: 1, form_factor: 'SFP', obs: 1103, avg_price: 35.11 },
|
||||||
{ label: 'QSFP28 100G', speed_gbps: 100, form_factor: 'QSFP28', obs: 369, avg_price: 409.46 },
|
{ label: 'QSFP28 100G', speed_gbps: 100, form_factor: 'QSFP28', obs: 369, avg_price: 409.46 },
|
||||||
@ -3406,15 +3477,29 @@ async function loadSourcingHypeCycle() {
|
|||||||
{ label: 'QSFP-DD 800G', speed_gbps: 800, form_factor: 'QSFP-DD', obs: 40, avg_price: 749.08 }
|
{ label: 'QSFP-DD 800G', speed_gbps: 800, form_factor: 'QSFP-DD', obs: 40, avg_price: 749.08 }
|
||||||
];
|
];
|
||||||
|
|
||||||
var items = seedData;
|
var items = seedFallback;
|
||||||
|
// Try real price observation counts per form_factor+speed_gbps (live DB data)
|
||||||
try {
|
try {
|
||||||
var d = await api('/api/procurement/signals?limit=30');
|
var priceCountRes = await api('/api/procurement/signals?limit=200');
|
||||||
if (d && d.signals && d.signals.length > 0) {
|
var sigItems = priceCountRes.data || [];
|
||||||
d.signals.forEach(function(sig) {
|
if (sigItems.length > 0) {
|
||||||
var key = (sig.form_factor || '') + String(sig.speed_gbps || '');
|
// Build a map from form_factor+speed_gbps → {obs, avg_price}
|
||||||
var ex = items.find(function(it) { return (it.form_factor || '') + String(it.speed_gbps || '') === key; });
|
var realMap = {};
|
||||||
if (ex && sig.observation_count > 0) ex.obs = sig.observation_count;
|
sigItems.forEach(function(row) {
|
||||||
|
var key = (row.form_factor || '') + '|' + String(row.speed_gbps || '');
|
||||||
|
if (!realMap[key] || row.observation_count > realMap[key].obs) {
|
||||||
|
realMap[key] = { obs: row.observation_count || 0, avg_price: parseFloat(row.avg_price) || 0 };
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
// Merge into items
|
||||||
|
items = items.map(function(it) {
|
||||||
|
var key = (it.form_factor || '') + '|' + String(it.speed_gbps || '');
|
||||||
|
if (realMap[key] && realMap[key].obs > 0) {
|
||||||
|
return Object.assign({}, it, { obs: realMap[key].obs, avg_price: realMap[key].avg_price || it.avg_price });
|
||||||
|
}
|
||||||
|
return it;
|
||||||
|
});
|
||||||
|
if (meta) meta.setAttribute('data-source', 'live');
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
@ -6769,7 +6854,7 @@ var procAiClustersData = [];
|
|||||||
var procAiClustersMinTx = 0;
|
var procAiClustersMinTx = 0;
|
||||||
|
|
||||||
function showProcSection(name) {
|
function showProcSection(name) {
|
||||||
['signals','abc','demand','ai-clusters','market','lifecycle'].forEach(function(s) {
|
['signals','abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) {
|
||||||
var sec = el('proc-section-' + s);
|
var sec = el('proc-section-' + s);
|
||||||
var btn = el('proc-btn-' + s);
|
var btn = el('proc-btn-' + s);
|
||||||
if (sec) sec.style.display = s === name ? '' : 'none';
|
if (sec) sec.style.display = s === name ? '' : 'none';
|
||||||
@ -6778,6 +6863,7 @@ function showProcSection(name) {
|
|||||||
// Lazy-load on first visit
|
// Lazy-load on first visit
|
||||||
if (name === 'demand' && procDemandData.length === 0) loadInternalDemand();
|
if (name === 'demand' && procDemandData.length === 0) loadInternalDemand();
|
||||||
if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters();
|
if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters();
|
||||||
|
if (name === 'marketplace' && !el('proc-marketplace-grid').querySelector('.card')) loadProcMarketplace();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProcurement() {
|
async function loadProcurement() {
|
||||||
@ -7144,6 +7230,167 @@ function filterAiClusters(minTx) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── MARKET SIGNALS — HYPE CYCLE ENRICHMENT ──────────────────────────────────
|
||||||
|
|
||||||
|
function renderHypeMarketContext(ctx, technologies) {
|
||||||
|
var el2 = el('hype-market-context');
|
||||||
|
if (!el2) return;
|
||||||
|
var capexYoy = ctx.capexYoyAvg || 0;
|
||||||
|
var capexColor = capexYoy > 80 ? '#16a34a' : capexYoy > 40 ? '#ca8a04' : '#64748b';
|
||||||
|
var totalAiTx = ctx.totalAiClusterTx90d || 0;
|
||||||
|
var fastTrend = ctx.internalFastMoverTrend || 0;
|
||||||
|
|
||||||
|
// Summary stat cards
|
||||||
|
var cards = [
|
||||||
|
statCard('💰', capexYoy.toFixed(0) + '% YoY avg', 'Hyperscaler CapEx',
|
||||||
|
(ctx.capexBoom ? '🚀 Boom — above +50% threshold' : 'Moderate growth') + ' (' + (ctx.hyperscalerCapex || []).map(function(c) { return c.company; }).join(', ') + ')'),
|
||||||
|
statCard('🤖', totalAiTx > 0 ? '~' + (totalAiTx / 1000).toFixed(0) + 'k Tx' : '—', 'AI Cluster Demand (90d)',
|
||||||
|
ctx.aiClusterCount90d + ' cluster builds with transceiver demand estimates'),
|
||||||
|
statCard('📈', fastTrend > 0 ? '+' + fastTrend.toFixed(1) + '%' : fastTrend.toFixed(1) + '%', 'Internal Fast-Mover Trend',
|
||||||
|
'Average demand trend across ' + (ctx.hyperscalerCapex || []).length + ' fast-moving SKUs'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Per-technology recommendation summary
|
||||||
|
var recCards = (technologies || []).map(function(ms) {
|
||||||
|
if (!ms.recommendation) return '';
|
||||||
|
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.6rem 0.75rem;min-width:160px">'
|
||||||
|
+ '<div style="font-size:0.75rem;font-weight:700;color:var(--text)">' + esc(ms.technology) + '</div>'
|
||||||
|
+ '<div style="font-size:0.72rem;font-weight:700;color:' + ms.recommendation.color + ';margin-top:0.2rem">' + esc(ms.recommendation.label) + '</div>'
|
||||||
|
+ '<div style="font-size:0.65rem;color:var(--text-dim);margin-top:0.15rem;line-height:1.4">' + esc(ms.recommendation.detail.substring(0, 80)) + (ms.recommendation.detail.length > 80 ? '…' : '') + '</div>'
|
||||||
|
+ '<div style="margin-top:0.4rem;display:flex;align-items:center;gap:0.4rem">'
|
||||||
|
+ '<div style="flex:1;height:3px;background:var(--border);border-radius:2px"><div style="width:' + ms.marketSignalScore + '%;height:100%;background:' + ms.recommendation.color + ';border-radius:2px"></div></div>'
|
||||||
|
+ '<span style="font-size:0.65rem;font-family:monospace;font-weight:700;color:' + ms.recommendation.color + '">' + ms.marketSignalScore + '</span>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
el2.innerHTML = '<div style="display:flex;gap:0.75rem;flex-wrap:wrap;margin-bottom:0.75rem">' + cards.join('') + '</div>'
|
||||||
|
+ '<div style="font-size:0.72rem;font-weight:700;color:var(--text-dim);margin-bottom:0.5rem;letter-spacing:0.05em;text-transform:uppercase">Recommendations per Technology</div>'
|
||||||
|
+ '<div style="display:flex;gap:0.75rem;flex-wrap:wrap">' + recCards + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CAPEX TABLE — in Hype Cycle tab ─────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadHypeCapexAndEbay() {
|
||||||
|
try {
|
||||||
|
var d = await api('/api/procurement/hyperscaler-capex');
|
||||||
|
renderCapexTable(d.data || [], d.summary || []);
|
||||||
|
} catch(e) {
|
||||||
|
var el2 = el('capex-table-container');
|
||||||
|
if (el2) el2.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">Could not load capex data.</div>';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var e = await api('/api/procurement/marketplace-velocity');
|
||||||
|
renderEbayVelocity(e.hot || e.data || []);
|
||||||
|
renderProcMarketplace(e.hot || e.data || []);
|
||||||
|
} catch(e2) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCapexTable(rows, summary) {
|
||||||
|
var badge = el('capex-avg-badge');
|
||||||
|
if (badge && summary.length) {
|
||||||
|
var avgYoy = summary.reduce(function(s, r) { return s + (parseFloat(r.latest_yoy_growth) || 0); }, 0) / summary.length;
|
||||||
|
badge.textContent = 'Avg YoY: +' + avgYoy.toFixed(0) + '% · ' + summary.length + ' hyperscalers';
|
||||||
|
badge.style.color = avgYoy > 80 ? '#16a34a' : avgYoy > 40 ? '#ca8a04' : '#2563eb';
|
||||||
|
badge.style.background = avgYoy > 80 ? '#16a34a11' : '#2563eb11';
|
||||||
|
badge.style.borderColor = avgYoy > 80 ? '#16a34a33' : '#2563eb33';
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = el('capex-table-container');
|
||||||
|
if (!container || !rows.length) return;
|
||||||
|
|
||||||
|
var html = '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:0.8rem">'
|
||||||
|
+ '<thead><tr style="border-bottom:2px solid var(--border);color:var(--text-dim);font-size:0.7rem;font-weight:700;text-transform:uppercase">'
|
||||||
|
+ '<th style="text-align:left;padding:8px 6px">Company</th>'
|
||||||
|
+ '<th style="text-align:left;padding:8px 6px">Period</th>'
|
||||||
|
+ '<th class="tip" data-tip="Total quarterly CapEx in millions USD (all infra)" style="text-align:right;padding:8px 6px">CapEx ($M)</th>'
|
||||||
|
+ '<th class="tip" data-tip="Estimated DC portion (≈55% of total CapEx based on analyst estimates)" style="text-align:right;padding:8px 6px">DC Est. ($M)</th>'
|
||||||
|
+ '<th class="tip" data-tip="Year-over-year growth of total CapEx vs same quarter prior year" style="text-align:right;padding:8px 6px">YoY %</th>'
|
||||||
|
+ '<th style="text-align:left;padding:8px 6px">Filing</th>'
|
||||||
|
+ '</tr></thead><tbody>';
|
||||||
|
|
||||||
|
rows.forEach(function(r) {
|
||||||
|
var yoy = parseFloat(r.yoy_growth_pct);
|
||||||
|
var yoyColor = yoy > 80 ? '#16a34a' : yoy > 40 ? '#ca8a04' : yoy > 0 ? '#2563eb' : '#ef4444';
|
||||||
|
var yoyStr = isNaN(yoy) ? '—' : (yoy > 0 ? '+' : '') + yoy.toFixed(1) + '%';
|
||||||
|
html += '<tr style="border-bottom:1px solid var(--border)">'
|
||||||
|
+ '<td style="padding:7px 6px;font-weight:700;text-transform:capitalize">' + esc(r.company) + '</td>'
|
||||||
|
+ '<td style="padding:7px 6px;color:var(--text-dim)">' + esc(r.period_label) + '</td>'
|
||||||
|
+ '<td style="padding:7px 6px;text-align:right;font-family:monospace;font-weight:600">$' + parseFloat(r.capex_usd_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '</td>'
|
||||||
|
+ '<td style="padding:7px 6px;text-align:right;font-family:monospace;color:var(--text-dim)">$' + parseFloat(r.dc_capex_est_millions).toLocaleString(undefined, {maximumFractionDigits: 0}) + '</td>'
|
||||||
|
+ '<td style="padding:7px 6px;text-align:right;font-weight:700;color:' + yoyColor + '">' + yoyStr + '</td>'
|
||||||
|
+ '<td style="padding:7px 6px;font-size:0.68rem;color:var(--text-dim)">' + esc(r.filing_type || '—') + '</td>'
|
||||||
|
+ '</tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html + '</tbody></table></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEbayVelocity(rows) {
|
||||||
|
var container = el('ebay-velocity-container');
|
||||||
|
if (!container || !rows.length) {
|
||||||
|
if (container) container.innerHTML = '<div style="color:var(--text-dim);padding:0.5rem">No marketplace data.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<div style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))">';
|
||||||
|
rows.forEach(function(r) {
|
||||||
|
var sold = parseInt(r.sold_count_30d) || 0;
|
||||||
|
var listings = parseInt(r.active_listings) || 0;
|
||||||
|
var sellThrough = listings > 0 ? ((sold / listings) * 100).toFixed(1) + '%' : '—';
|
||||||
|
var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)';
|
||||||
|
var avgPrice = parseFloat(r.avg_sold_price);
|
||||||
|
html += '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.8rem">'
|
||||||
|
+ '<div style="font-size:0.82rem;font-weight:700">' + esc(r.form_factor || r.keyword || '?') + ' ' + esc(r.speed_label || '') + '</div>'
|
||||||
|
+ '<div style="display:flex;align-items:baseline;gap:0.4rem;margin-top:0.4rem">'
|
||||||
|
+ '<span style="font-size:1.5rem;font-weight:800;color:' + demandColor + '">' + sold.toLocaleString() + '</span>'
|
||||||
|
+ '<span style="font-size:0.72rem;color:var(--text-dim)">sold / 30d</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.2rem">'
|
||||||
|
+ listings.toLocaleString() + ' active listings · sell-through ' + sellThrough
|
||||||
|
+ '</div>'
|
||||||
|
+ (avgPrice > 0 ? '<div style="font-size:0.72rem;color:var(--text-dim)">Avg sold: $' + avgPrice.toLocaleString(undefined,{maximumFractionDigits:0}) + '</div>' : '')
|
||||||
|
+ '</div>';
|
||||||
|
});
|
||||||
|
container.innerHTML = html + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PROC MARKETPLACE SECTION ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadProcMarketplace() {
|
||||||
|
var grid = el('proc-marketplace-grid');
|
||||||
|
try {
|
||||||
|
var d = await api('/api/procurement/marketplace-velocity');
|
||||||
|
renderProcMarketplace(d.hot || d.data || []);
|
||||||
|
} catch(e) {
|
||||||
|
if (grid) grid.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Could not load marketplace data.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProcMarketplace(rows) {
|
||||||
|
var grid = el('proc-marketplace-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
if (!rows.length) { grid.innerHTML = '<div style="color:var(--text-dim)">No data yet.</div>'; return; }
|
||||||
|
grid.innerHTML = rows.map(function(r) {
|
||||||
|
var sold = parseInt(r.sold_count_30d) || 0;
|
||||||
|
var listings = parseInt(r.active_listings) || 0;
|
||||||
|
var demandColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : sold > 50 ? '#2563eb' : 'var(--text-dim)';
|
||||||
|
var borderColor = sold > 200 ? '#16a34a' : sold > 100 ? '#ca8a04' : 'var(--border)';
|
||||||
|
var avgP = parseFloat(r.avg_sold_price);
|
||||||
|
return '<div class="card" style="border-left:3px solid ' + borderColor + '">'
|
||||||
|
+ '<div style="font-weight:700;font-size:0.9rem">' + esc(r.form_factor || '?') + ' ' + esc(r.speed_label || '') + '</div>'
|
||||||
|
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem">' + esc(r.keyword || '') + ' · ' + esc(r.marketplace || 'eBay') + '</div>'
|
||||||
|
+ '<div style="display:flex;align-items:baseline;gap:0.5rem">'
|
||||||
|
+ '<span style="font-size:1.8rem;font-weight:800;color:' + demandColor + '">' + sold.toLocaleString() + '</span>'
|
||||||
|
+ '<span style="font-size:0.75rem;color:var(--text-dim)">sold / 30d</span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div style="font-size:0.72rem;color:var(--text-dim);margin-top:0.25rem">'
|
||||||
|
+ listings.toLocaleString() + ' active listings'
|
||||||
|
+ (avgP > 0 ? ' · avg $' + avgP.toLocaleString(undefined,{maximumFractionDigits:0}) : '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
// INIT
|
// INIT
|
||||||
loadOverview();
|
loadOverview();
|
||||||
loadChangelog();
|
loadChangelog();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user