diff --git a/packages/api/src/routes/transceivers.ts b/packages/api/src/routes/transceivers.ts index cf9a5ce..9c48797 100644 --- a/packages/api/src/routes/transceivers.ts +++ b/packages/api/src/routes/transceivers.ts @@ -53,7 +53,7 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => { [transceiver.id] ); - // Flag: price is only "verified" if also observed within 7 days + // Flag: price is only "verified" if observed within 7 days with real URL const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const prices = pricesResult.rows.map((row) => ({ vendor_name: row.vendor_name, @@ -64,11 +64,61 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => { url: row.url, stock_level: row.stock_level, observed_at: row.time, - // Verified = scraped from real URL + observed within 7 days - is_verified: row.url && new Date(row.time) > sevenDaysAgo, + is_verified: !!(row.url && new Date(row.time) > sevenDaysAgo), + is_same_product: true, })); - res.json({ success: true, data: { ...transceiver, competitor_prices: prices } }); + // Market comparison: technically equivalent products from other vendors + // Same form_factor + speed_gbps + reach within 20% tolerance, different vendor + // Only real scraped prices (url IS NOT NULL), last 30 days, max 1 per source vendor + const comparableResult = await pool.query( + `SELECT DISTINCT ON (po.source_vendor_id) + po.price, po.currency, po.url, po.time, + sv.name AS vendor_name, sv.type AS vendor_type, + t2.part_number, t2.standard_name, t2.id AS comparable_id + FROM transceivers t1 + JOIN transceivers t2 ON ( + t2.form_factor = t1.form_factor + AND t2.speed_gbps = t1.speed_gbps + AND ( + t1.reach_meters IS NULL OR t2.reach_meters IS NULL + OR ABS(t2.reach_meters - t1.reach_meters) <= GREATEST(t1.reach_meters, 1) * 0.25 + ) + AND t2.id != t1.id + ) + JOIN price_observations po ON po.transceiver_id = t2.id + JOIN vendors sv ON po.source_vendor_id = sv.id + WHERE t1.id = $1 + AND po.time > NOW() - INTERVAL '30 days' + AND po.price > 0 + AND po.url IS NOT NULL + -- Exclude vendors that already appear in direct prices + AND sv.id NOT IN ( + SELECT source_vendor_id FROM price_observations + WHERE transceiver_id = $1 AND time > NOW() - INTERVAL '30 days' + ) + ORDER BY po.source_vendor_id, po.time DESC + LIMIT 10`, + [transceiver.id] + ); + + const comparablePrices = comparableResult.rows.map((row) => ({ + vendor_name: row.vendor_name, + vendor_type: row.vendor_type, + price: parseFloat(row.price), + currency: row.currency, + url: row.url, + observed_at: row.time, + is_verified: !!(row.url && new Date(row.time) > sevenDaysAgo), + is_same_product: false, // different SKU, same spec class + comparable_part: row.part_number || row.standard_name, + comparable_id: row.comparable_id, + })); + + res.json({ + success: true, + data: { ...transceiver, competitor_prices: [...prices, ...comparablePrices] }, + }); } catch (err) { console.error("Get transceiver error:", err); res.status(500).json({ success: false, error: "Internal server error" }); diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index 6b6146a..eeb4370 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -1953,26 +1953,40 @@ async function openTxDetail(id) { ]); // SPECIFICATION — Pricing - // Rule: Only show prices that were scraped from a real URL within the last 30 days. - // Unverified / estimated prices are NOT shown — silence is better than wrong data. - var prices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; }); - if (prices.length > 0) { + // Rule: Only prices with real URL from last 30 days. No estimated/fallback prices. + var allPrices = (t.competitor_prices || []).filter(function(p) { return p.url && p.price > 0; }); + var directPrices = allPrices.filter(function(p) { return p.is_same_product !== false; }); + var comparPrices = allPrices.filter(function(p) { return p.is_same_product === false; }); + + if (allPrices.length > 0) { h += '
Current Prices
'; h += '
'; - prices.forEach(function(p) { - // Verified badge only if observed within 7 days AND has a real URL + + function renderPriceRow(p) { var verBadge = p.is_verified === true - ? '✓ Verified Price' - : ''; // No badge for older observations — not wrong, just not shown as verified - var priceStr = '' + p.currency + '\u00a0' + parseFloat(p.price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ''; - var dateStr = 'Stand: ' + fmtDate(p.observed_at) + ''; - var urlLink = '↗ Quelle'; - h += '
' + esc(p.vendor_name) + '' - + '' + priceStr + verBadge + dateStr + urlLink + '
'; - }); + ? '✓ Verified' + : ''; + var priceStr = '' + p.currency + '\u00a0' + parseFloat(p.price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2}) + ''; + var dateStr = 'Stand: ' + fmtDate(p.observed_at) + ''; + var urlLink = ''; + var label = esc(p.vendor_name); + if (p.is_same_product === false && p.comparable_part) { + label += '
' + esc(p.comparable_part) + '
'; + } + return '
' + label + '' + + '' + priceStr + verBadge + dateStr + urlLink + '
'; + } + + directPrices.forEach(function(p) { h += renderPriceRow(p); }); + + if (comparPrices.length > 0) { + h += '
Vergleichbare Produkte anderer Hersteller (gleiche Spezifikation)
'; + comparPrices.forEach(function(p) { h += renderPriceRow(p); }); + } + h += '
'; } - // No price_observations → show nothing. Never display estimated or unverifiable prices. + // No price_observations → show nothing. Never display estimated prices. // Notes (scraped extra specs) if (t.notes) {