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 += '