Merges 28 Gitea commits (Transceiver Academy, dashboard features A-P, equivalence
matchers OPN/spec, Abverkauf velocity engine, Flexoptix detail sync, SQL 111-118,
MCP equivalences tool, training data) with 22 Erik-local commits (price chart,
fmtSpd, supply-squeeze per-SKU fix, research-robot actions, NADDOD warehouse,
FS competitor stock, reorder bloat fix, flexoptix empty-token fix, vendor
reliability, blog WireGuard failover).
Only 2 files changed on both sides; ort auto-merged both cleanly. Manually
resolved a resulting duplicate GET /api/hype-cycle/market-signals: kept the
Erik handler (technologies+marketSignalScore+drivers+globalContext, matches the
merged dashboard), removed the Gitea Multi-source variant (preserved in history).
All 3 packages build with 0 TS errors. Safety: branch erik-pre-reconcile-20260607,
bundle _RECON_full-backup-20260607.bundle (also on Fearghas), bulk-price WIP in
stash@{0}, untracked SQL backed up to _RECON_untracked_sql_20260607/.
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
/**
|
|
* Hype Cycle API routes
|
|
*
|
|
* GET /api/hype-cycle — All technologies with current phase
|
|
* GET /api/hype-cycle/enriched — All technologies with data-driven metrics
|
|
* GET /api/hype-cycle/lifecycle — Revenue lifecycle predictions for all speeds
|
|
* GET /api/hype-cycle/regional/:tech — Regional adoption model for a technology
|
|
* GET /api/hype-cycle/:tech — Specific technology with 5-year forecast
|
|
*/
|
|
import { Router, Request, Response } from "express";
|
|
import {
|
|
computeAllHypeCycles,
|
|
computeHypeCycle,
|
|
findTechnology,
|
|
TECH_GENERATIONS,
|
|
SPECIAL_TECHS,
|
|
} from "../hype-cycle/norton-bass";
|
|
import {
|
|
getDataDrivenOverrides,
|
|
getSpeedClassMetrics,
|
|
computeRevenueLifecycle,
|
|
computeRegionalAdoption,
|
|
} from "../hype-cycle/data-enrichment";
|
|
|
|
export const hypeCycleRouter = Router();
|
|
|
|
const q = (p: string, req: Request): string | undefined =>
|
|
req.query[p] ? String(req.query[p]) : undefined;
|
|
|
|
// GET /api/hype-cycle — All technologies (model-only, fast)
|
|
hypeCycleRouter.get("/", (_req: Request, res: Response) => {
|
|
const yearParam = q("year", _req);
|
|
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear();
|
|
|
|
const results = computeAllHypeCycles(year);
|
|
const sorted = [...results].sort((a, b) => a.positionPct - b.positionPct);
|
|
|
|
res.json({
|
|
success: true,
|
|
year,
|
|
model: "Norton-Bass Multigenerational Diffusion",
|
|
technologies: sorted.map((r) => ({
|
|
technology: r.technology,
|
|
phase: r.phaseLabel,
|
|
positionPct: r.positionPct,
|
|
adoptionPct: r.adoptionPct,
|
|
compositeScore: r.compositeScore,
|
|
peakYear: r.forecast.peakShipmentYear,
|
|
yearsToPlateauFromNow: r.forecast.yearsToPlateauFromNow,
|
|
})),
|
|
});
|
|
});
|
|
|
|
// GET /api/hype-cycle/enriched — Data-driven enrichment from scraped data
|
|
hypeCycleRouter.get("/enriched", async (_req: Request, res: Response) => {
|
|
try {
|
|
const yearParam = q("year", _req);
|
|
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear();
|
|
|
|
const overridesMap = await getDataDrivenOverrides();
|
|
const allTechs = [...TECH_GENERATIONS, ...SPECIAL_TECHS];
|
|
|
|
const results = allTechs.map((tech) => {
|
|
const overrides = overridesMap.get(tech.speedGbps);
|
|
return computeHypeCycle(tech, year, overrides);
|
|
});
|
|
|
|
const sorted = [...results].sort((a, b) => a.positionPct - b.positionPct);
|
|
|
|
// Also include raw metrics for transparency
|
|
const speedMetrics = await getSpeedClassMetrics();
|
|
|
|
res.json({
|
|
success: true,
|
|
year,
|
|
model: "Norton-Bass + Data-Driven Enrichment",
|
|
dataSource: {
|
|
totalTransceivers: speedMetrics.reduce((s, m) => s + m.skuCount, 0),
|
|
totalPricePoints: speedMetrics.reduce((s, m) => s + m.priceCount, 0),
|
|
speedClasses: speedMetrics.length,
|
|
},
|
|
technologies: sorted.map((r) => ({
|
|
technology: r.technology,
|
|
phase: r.phaseLabel,
|
|
positionPct: r.positionPct,
|
|
adoptionPct: r.adoptionPct,
|
|
compositeScore: r.compositeScore,
|
|
peakYear: r.forecast.peakShipmentYear,
|
|
yearsToPlateauFromNow: r.forecast.yearsToPlateauFromNow,
|
|
metrics: r.metrics,
|
|
fiveYearForecast: r.forecast.fiveYearProjection,
|
|
})),
|
|
rawSpeedMetrics: speedMetrics.map((m) => ({
|
|
speedGbps: m.speedGbps,
|
|
vendorCount: m.vendorCount,
|
|
skuCount: m.skuCount,
|
|
avgPrice: m.avgPrice ? Math.round(m.avgPrice * 100) / 100 : null,
|
|
minPrice: m.minPrice ? Math.round(m.minPrice * 100) / 100 : null,
|
|
maxPrice: m.maxPrice ? Math.round(m.maxPrice * 100) / 100 : null,
|
|
formFactors: m.formFactors,
|
|
reachVariants: m.reachVariants,
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error("Enriched hype cycle error:", err);
|
|
res.status(500).json({ success: false, error: "Failed to compute enriched hype cycle" });
|
|
}
|
|
});
|
|
|
|
// GET /api/hype-cycle/lifecycle — Revenue lifecycle predictions
|
|
hypeCycleRouter.get("/lifecycle", async (_req: Request, res: Response) => {
|
|
try {
|
|
const currentYear = new Date().getFullYear();
|
|
const speedMetrics = await getSpeedClassMetrics();
|
|
const priceMap = new Map(speedMetrics.map((m) => [m.speedGbps, m.avgPrice]));
|
|
|
|
const allTechs = [...TECH_GENERATIONS, ...SPECIAL_TECHS];
|
|
const lifecycles = allTechs.map((tech) =>
|
|
computeRevenueLifecycle(
|
|
tech.speedGbps,
|
|
tech.name,
|
|
tech.introYear,
|
|
tech.peakYear,
|
|
currentYear,
|
|
priceMap.get(tech.speedGbps),
|
|
)
|
|
);
|
|
|
|
// Sort by revenue index (highest current revenue first)
|
|
const sorted = [...lifecycles].sort((a, b) => b.revenueIndex - a.revenueIndex);
|
|
|
|
res.json({
|
|
success: true,
|
|
currentYear,
|
|
lifecycles: sorted,
|
|
});
|
|
} catch (err) {
|
|
console.error("Lifecycle error:", err);
|
|
res.status(500).json({ success: false, error: "Failed to compute lifecycles" });
|
|
}
|
|
});
|
|
|
|
// GET /api/hype-cycle/regional/:tech — Regional adoption by technology
|
|
hypeCycleRouter.get("/regional/:tech", (req: Request, res: Response) => {
|
|
const techQuery = String(req.params.tech);
|
|
const currentYear = new Date().getFullYear();
|
|
|
|
const tech = findTechnology(techQuery);
|
|
if (!tech) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: `Technology "${techQuery}" not found. Available: 1G, 10G, 25G, 40G, 100G, 400G, 800G, 1.6T, CPO, LPO, 400ZR`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const regions = computeRegionalAdoption(tech.peakYear, currentYear, tech.name);
|
|
|
|
res.json({
|
|
success: true,
|
|
technology: tech.name,
|
|
speedGbps: tech.speedGbps,
|
|
globalPeakYear: tech.peakYear,
|
|
regions,
|
|
});
|
|
});
|
|
|
|
// ── 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/analysis — Bass-fitted results from DB (hype_cycle_analysis table)
|
|
hypeCycleRouter.get("/analysis", async (_req: Request, res: Response) => {
|
|
try {
|
|
const { pool } = await import("../db/client");
|
|
const { rows } = await pool.query(`
|
|
SELECT DISTINCT ON (technology)
|
|
technology, computed_at,
|
|
bass_p, bass_q, bass_m,
|
|
t_peak_year, current_t,
|
|
ROUND(current_share * 100, 1) AS share_pct,
|
|
ROUND(projected_share_1y * 100, 1) AS share_1y_pct,
|
|
ROUND(projected_share_3y * 100, 1) AS share_3y_pct,
|
|
hype_phase, hype_score,
|
|
phase_since_year, years_to_next_phase,
|
|
asp_current_usd, asp_decline_pct_3y,
|
|
r_squared, data_points, notes
|
|
FROM hype_cycle_analysis
|
|
ORDER BY technology, computed_at DESC
|
|
`);
|
|
|
|
if (rows.length === 0) {
|
|
res.json({ success: true, source: "db", data: [], message: "No analysis yet — run compute:hype-cycle job" });
|
|
return;
|
|
}
|
|
|
|
res.json({ success: true, source: "db", computed_at: rows[0]?.computed_at, data: rows });
|
|
} catch (err) {
|
|
console.error("Hype cycle analysis DB error:", err);
|
|
res.status(500).json({ success: false, error: "Failed to fetch analysis" });
|
|
}
|
|
});
|
|
|
|
// GET /api/hype-cycle/:tech — Specific technology detail (must be last!)
|
|
// GET /api/hype-cycle/market-signals — data-driven per-technology market signal score + drivers + recommendation
|
|
hypeCycleRouter.get("/market-signals", async (_req: Request, res: Response) => {
|
|
try {
|
|
const yearParam = q("year", _req);
|
|
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear();
|
|
const overridesMap = await getDataDrivenOverrides();
|
|
const speedMetrics = await getSpeedClassMetrics();
|
|
const metricsBySpeed = new Map(speedMetrics.map((m) => [m.speedGbps, m]));
|
|
const maxSku = Math.max(1, ...speedMetrics.map((m) => m.skuCount));
|
|
const allTechs = [...TECH_GENERATIONS, ...SPECIAL_TECHS];
|
|
|
|
const technologies = allTechs.map((tech) => {
|
|
const r = computeHypeCycle(tech, year, overridesMap.get(tech.speedGbps));
|
|
const sm = metricsBySpeed.get(tech.speedGbps);
|
|
const adoption = Math.max(0, Math.round(r.adoptionPct));
|
|
const composite = Math.max(0, Math.round(r.compositeScore));
|
|
const depth = sm ? Math.round(100 * Math.min(1, sm.skuCount / maxSku)) : 0;
|
|
const marketSignalScore = Math.max(0, Math.min(100, Math.round(0.4 * adoption + 0.3 * composite + 0.3 * depth)));
|
|
|
|
const drivers: string[] = [];
|
|
drivers.push(adoption + "% modeled adoption");
|
|
if (sm && sm.skuCount) drivers.push(sm.skuCount + " SKUs / " + sm.vendorCount + " vendors");
|
|
if (sm && sm.avgPrice) drivers.push("avg $" + Math.round(sm.avgPrice));
|
|
drivers.push("phase: " + r.phaseLabel);
|
|
|
|
const phase = String(r.phaseLabel || "");
|
|
let recommendation = { label: "Monitor", detail: "Insufficient market momentum — keep watching.", color: "#94a3b8" };
|
|
if (/Plateau/i.test(phase)) recommendation = { label: "Commodity — negotiate", detail: "Mature, broad supply (" + (sm ? sm.vendorCount : 0) + " vendors). Push for price.", color: "#0ea5e9" };
|
|
else if (/Slope/i.test(phase)) recommendation = { label: "Mainstream — buy", detail: "Mainstreaming with falling prices and rising supply.", color: "#16a34a" };
|
|
else if (/Trough/i.test(phase)) recommendation = { label: "Maturing — opportunity", detail: "Past the hype; early mainstream pricing forming.", color: "#ca8a04" };
|
|
else if (/Peak/i.test(phase)) recommendation = { label: "Hype peak — caution", detail: "Elevated expectations; supply and price still volatile.", color: "#f97316" };
|
|
else if (/Innovation|Trigger/i.test(phase)) recommendation = { label: "Emerging — pilot", detail: "Early window; limited supply, premium pricing.", color: "#ca8a04" };
|
|
|
|
return {
|
|
technology: tech.name,
|
|
phase: r.phaseLabel,
|
|
marketSignalScore,
|
|
adoptionPct: adoption,
|
|
compositeScore: composite,
|
|
skuCount: sm ? sm.skuCount : 0,
|
|
vendorCount: sm ? sm.vendorCount : 0,
|
|
avgPrice: sm && sm.avgPrice ? Math.round(sm.avgPrice * 100) / 100 : null,
|
|
drivers,
|
|
recommendation,
|
|
};
|
|
});
|
|
|
|
technologies.sort((a, b) => b.marketSignalScore - a.marketSignalScore);
|
|
|
|
const totalSkus = speedMetrics.reduce((acc, m) => acc + m.skuCount, 0);
|
|
const totalVendors = speedMetrics.length ? Math.max(...speedMetrics.map((m) => m.vendorCount)) : 0;
|
|
const avgSignal = technologies.length ? Math.round(technologies.reduce((acc, t) => acc + t.marketSignalScore, 0) / technologies.length) : 0;
|
|
const globalContext = {
|
|
totalSkus,
|
|
totalVendors,
|
|
avgMarketSignal: avgSignal,
|
|
strongest: technologies.length ? technologies[0].technology : null,
|
|
strongestScore: technologies.length ? technologies[0].marketSignalScore : null,
|
|
generatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
res.json({ success: true, year, technologies, globalContext });
|
|
} catch (err) {
|
|
console.error("market-signals error:", err);
|
|
res.status(500).json({ success: false, error: "Failed to compute market signals" });
|
|
}
|
|
});
|
|
|
|
hypeCycleRouter.get("/:tech", (req: Request, res: Response) => {
|
|
const techQuery = String(req.params.tech);
|
|
const yearParam = q("year", req);
|
|
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear();
|
|
|
|
const tech = findTechnology(techQuery);
|
|
if (!tech) {
|
|
res.status(404).json({
|
|
success: false,
|
|
error: `Technology "${techQuery}" not found. Available: 1G, 10G, 25G, 40G, 100G, 400G, 800G, 1.6T, CPO, LPO, 400ZR`,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const result = computeHypeCycle(tech, year);
|
|
|
|
// Add regional data
|
|
const regions = computeRegionalAdoption(tech.peakYear, year, tech.name);
|
|
|
|
// Add revenue lifecycle
|
|
const lifecycle = computeRevenueLifecycle(
|
|
tech.speedGbps,
|
|
tech.name,
|
|
tech.introYear,
|
|
tech.peakYear,
|
|
year,
|
|
);
|
|
|
|
res.json({
|
|
success: true,
|
|
...result,
|
|
regionalAdoption: regions,
|
|
revenueLifecycle: lifecycle,
|
|
});
|
|
});
|