Rene Fichtmueller e71b985c52 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
2026-05-14 21:39:17 +02:00

112 lines
3.8 KiB
TypeScript

/**
* 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) });
}
});