feat: temp range display, verification badges, competitor prices, tag tooltips

- Temperature Range: COM→'0-70°C (COM)', IND→'-40-85°C (IND)'
- GET /api/transceivers/🆔 returns competitor_prices[] from price_observations
- Detail view: verification summary bar (★ 100% VERIFIED / partial)
- Detail view: Current Prices section with vendor, price, verified badge, date, link
- Detail view: tag tooltips on vendor/category/market_status chips
- List view: new Verified column with 100% stamp or price check
- Optical Budget: TX Power Min/Max labels clarified
This commit is contained in:
Rene Fichtmueller 2026-04-01 20:47:02 +02:00
parent cd48eee316
commit 3811b3b953
2 changed files with 105 additions and 16 deletions

View File

@ -31,7 +31,7 @@ transceiverRouter.get("/", async (req: Request, res: Response) => {
} }
}); });
// GET /api/transceivers/:id — Get single transceiver // GET /api/transceivers/:id — Get single transceiver with latest prices per vendor
transceiverRouter.get("/:id", async (req: Request, res: Response) => { transceiverRouter.get("/:id", async (req: Request, res: Response) => {
try { try {
const transceiver = await getTransceiverById(String(req.params.id)); const transceiver = await getTransceiverById(String(req.params.id));
@ -39,7 +39,36 @@ transceiverRouter.get("/:id", async (req: Request, res: Response) => {
res.status(404).json({ success: false, error: "Transceiver not found" }); res.status(404).json({ success: false, error: "Transceiver not found" });
return; return;
} }
res.json({ success: true, data: transceiver });
// Latest price per source vendor — last 30 days only
const pricesResult = await pool.query(
`SELECT DISTINCT ON (po.source_vendor_id)
po.price, po.currency, po.url, po.time, po.stock_level, po.is_verified,
v.name AS vendor_name, v.type AS vendor_type, v.website AS vendor_website
FROM price_observations po
JOIN vendors v ON po.source_vendor_id = v.id
WHERE po.transceiver_id = $1
AND po.time > NOW() - INTERVAL '30 days'
ORDER BY po.source_vendor_id, po.time DESC`,
[transceiver.id]
);
// Flag: price is only "verified" if also observed within 7 days
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const prices = pricesResult.rows.map((row) => ({
vendor_name: row.vendor_name,
vendor_type: row.vendor_type,
vendor_website: row.vendor_website,
price: parseFloat(row.price),
currency: row.currency,
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,
}));
res.json({ success: true, data: { ...transceiver, competitor_prices: prices } });
} catch (err) { } catch (err) {
console.error("Get transceiver error:", err); console.error("Get transceiver error:", err);
res.status(500).json({ success: false, error: "Internal server error" }); res.status(500).json({ success: false, error: "Internal server error" });

View File

@ -823,7 +823,7 @@
<div class="card"> <div class="card">
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead><tr><th style="width:30px"></th><th>Name<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Form Factor<span class="sort-arrow"></span></th><th>Speed<span class="sort-arrow"></span></th><th>Reach<span class="sort-arrow"></span></th><th>Price<span class="sort-arrow"></span></th><th>Tier<span class="sort-arrow"></span></th><th>Avail.<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th></tr></thead> <thead><tr><th style="width:30px"></th><th>Name<span class="sort-arrow"></span></th><th>Vendor<span class="sort-arrow"></span></th><th>Form Factor<span class="sort-arrow"></span></th><th>Speed<span class="sort-arrow"></span></th><th>Reach<span class="sort-arrow"></span></th><th>Price<span class="sort-arrow"></span></th><th>Tier<span class="sort-arrow"></span></th><th>Avail.<span class="sort-arrow"></span></th><th>Category<span class="sort-arrow"></span></th><th>Verified</th></tr></thead>
<tbody id="tx-table"></tbody> <tbody id="tx-table"></tbody>
</table> </table>
</div> </div>
@ -959,6 +959,20 @@ function buildDOM(parent, html) {
parent.appendChild(t.content.cloneNode(true)); parent.appendChild(t.content.cloneNode(true));
} }
// Temperature range code → human-readable display
function tempRangeDisplay(code) {
if (!code) return null;
var map = { 'COM': '0 70 °C (COM)', 'IND': '-40 85 °C (IND)', 'EXT': '-40 70 °C (EXT)' };
return map[code] || code;
}
// Format observed date as "DD.MM.YYYY"
function fmtDate(iso) {
if (!iso) return '';
var d = new Date(iso);
return d.getDate().toString().padStart(2,'0') + '.' + (d.getMonth()+1).toString().padStart(2,'0') + '.' + d.getFullYear();
}
// Build a human-readable descriptive product name from available fields // Build a human-readable descriptive product name from available fields
function txDescName(t) { function txDescName(t) {
// Use description field if populated and meaningful (not just the SKU) // Use description field if populated and meaningful (not just the SKU)
@ -1779,6 +1793,10 @@ function searchTransceivers() {
+ '<td>' + (t.price_tier ? '<span class="b ' + (t.price_tier === 'Premium' ? 'b-purple' : t.price_tier === 'Budget' ? 'b-green' : 'b-neutral') + '">' + esc(t.price_tier) + '</span>' : '—') + '</td>' + '<td>' + (t.price_tier ? '<span class="b ' + (t.price_tier === 'Premium' ? 'b-purple' : t.price_tier === 'Budget' ? 'b-green' : 'b-neutral') + '">' + esc(t.price_tier) + '</span>' : '—') + '</td>'
+ '<td>' + (t.market_status ? '<span class="b b-green">' + esc(t.market_status) + '</span>' : '—') + '</td>' + '<td>' + (t.market_status ? '<span class="b b-green">' + esc(t.market_status) + '</span>' : '—') + '</td>'
+ '<td>' + (t.category ? '<span class="b b-neutral">' + esc(t.category) + '</span>' : '') + '</td>' + '<td>' + (t.category ? '<span class="b b-neutral">' + esc(t.category) + '</span>' : '') + '</td>'
+ '<td>' + (t.fully_verified
? '<span style="background:linear-gradient(135deg,#1b4332,#2d6a4f);color:#fff;font-size:0.62rem;font-weight:700;padding:2px 6px;border-radius:4px;white-space:nowrap">★ 100%</span>'
: t.price_verified ? '<span style="color:#2d6a4f;font-size:0.68rem;font-weight:600">✓ Price</span>' : '')
+ '</td>'
+ '</tr>'; + '</tr>';
}).join('')); }).join(''));
@ -1818,9 +1836,15 @@ async function openTxDetail(id) {
// Title + Vendor badge // Title + Vendor badge
h += '<div class="panel-title">' + esc(t.standard_name || t.slug) + '</div>'; h += '<div class="panel-title">' + esc(t.standard_name || t.slug) + '</div>';
h += '<div class="panel-sub">'; h += '<div class="panel-sub">';
if (t.vendor_name) h += '<span class="b b-blue">' + esc(t.vendor_name) + '</span> '; if (t.vendor_name) h += '<span class="b b-blue" title="Hersteller / Marke dieses Produkts">' + esc(t.vendor_name) + '</span> ';
if (t.category) h += '<span class="b b-neutral">' + esc(t.category) + '</span> '; if (t.category) h += '<span class="b b-neutral" title="Einsatzbereich: ' + esc(t.category) + '">' + esc(t.category) + '</span> ';
if (t.market_status) h += '<span class="b ' + (t.market_status === 'Mainstream' ? 'b-green' : t.market_status === 'Emerging' ? 'b-yellow' : 'b-neutral') + '">' + esc(t.market_status) + '</span>'; if (t.market_status) {
var msTooltip = t.market_status === 'Emerging' ? 'Hype Cycle: Technologie gewinnt erste Akzeptanz wachsendes Ökosystem, noch Premium-Preise'
: t.market_status === 'Mainstream' ? 'Hype Cycle: Technologie ist etabliert breite Verfügbarkeit, kompetitive Preise'
: t.market_status === 'Legacy' ? 'Hype Cycle: Technologie veraltet wird durch neuere Generationen abgelöst'
: 'Marktphase: ' + t.market_status;
h += '<span class="b ' + (t.market_status === 'Mainstream' ? 'b-green' : t.market_status === 'Emerging' ? 'b-yellow' : 'b-neutral') + '" title="' + msTooltip + '">' + esc(t.market_status) + '</span>';
}
h += '</div>'; h += '</div>';
if (t.description) h += '<div style="font-size:0.8rem;color:var(--text-dim);margin:0.5rem 0">' + esc(t.description) + '</div>'; if (t.description) h += '<div style="font-size:0.8rem;color:var(--text-dim);margin:0.5rem 0">' + esc(t.description) + '</div>';
@ -1832,6 +1856,24 @@ async function openTxDetail(id) {
h += '<div class="panel-stat"><div class="panel-stat-label">Fiber</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.fiber_type || '—') + '</div></div>'; h += '<div class="panel-stat"><div class="panel-stat-label">Fiber</div><div class="panel-stat-val" style="font-size:1rem">' + esc(t.fiber_type || '—') + '</div></div>';
h += '</div>'; h += '</div>';
// Verification summary bar
var verItems = [];
if (t.price_verified) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Price</span>');
if (t.image_verified) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Image</span>');
if (t.details_verified) verItems.push('<span style="color:#2d6a4f;font-size:0.75rem">✓ Details</span>');
if (t.fully_verified) {
h += '<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;margin:0.8rem 0;padding:0.5rem 0.75rem;background:linear-gradient(135deg,#1b4332,#2d6a4f);border-radius:8px">'
+ '<span style="color:#fff;font-size:0.8rem;font-weight:700;letter-spacing:0.03em">★ 100% VERIFIED</span>'
+ '<span style="color:rgba(255,255,255,0.6);font-size:0.7rem"></span>'
+ verItems.join('<span style="color:rgba(255,255,255,0.4);font-size:0.7rem">·</span>')
+ (t.fully_verified_at ? '<span style="color:rgba(255,255,255,0.5);font-size:0.68rem;margin-left:auto">seit ' + fmtDate(t.fully_verified_at) + '</span>' : '')
+ '</div>';
} else if (verItems.length > 0) {
h += '<div style="display:flex;align-items:center;gap:0.5rem;flex-wrap:wrap;margin:0.6rem 0;padding:0.4rem 0.6rem;background:rgba(45,106,79,0.08);border:1px solid rgba(45,106,79,0.2);border-radius:6px">'
+ verItems.join('<span style="color:#aaa;font-size:0.7rem">·</span>')
+ '</div>';
}
// Helper: render a spec section as a clean table (like Flexoptix spec tables) // Helper: render a spec section as a clean table (like Flexoptix spec tables)
function renderSpecTable(title, rows) { function renderSpecTable(title, rows) {
var visible = rows.filter(function(r) { return r[1] != null && r[1] !== '' && r[1] !== false; }); var visible = rows.filter(function(r) { return r[1] != null && r[1] !== '' && r[1] !== false; });
@ -1856,7 +1898,7 @@ async function openTxDetail(id) {
['Tunable', t.tunable ? 'Yes' : null], ['Tunable', t.tunable ? 'Yes' : null],
['ITU Grid', t.itu_grid], ['ITU Grid', t.itu_grid],
['Coherent', t.coherent ? 'Yes' : null], ['Coherent', t.coherent ? 'Yes' : null],
['Temperature Range', t.temp_range], ['Temperature Range', tempRangeDisplay(t.temp_range)],
]); ]);
// SPECIFICATION — Performance // SPECIFICATION — Performance
@ -1875,9 +1917,9 @@ async function openTxDetail(id) {
// SPECIFICATION — Optical Budget // SPECIFICATION — Optical Budget
h += renderSpecTable('Optical Budget', [ h += renderSpecTable('Optical Budget', [
['Power Budget', t.optical_budget_db ? t.optical_budget_db + ' dB' : null], ['Power Budget', t.optical_budget_db ? t.optical_budget_db + ' dB' : null],
['Tx Power Min', t.tx_power_min_dbm ? t.tx_power_min_dbm + ' dBm' : null], ['TX Power (Min)', t.tx_power_min_dbm != null ? t.tx_power_min_dbm + ' dBm' : null],
['Tx Power Max', t.tx_power_max_dbm ? t.tx_power_max_dbm + ' dBm' : null], ['TX Power (Max)', t.tx_power_max_dbm != null ? t.tx_power_max_dbm + ' dBm' : null],
['Rx Sensitivity', t.rx_sensitivity_dbm ? t.rx_sensitivity_dbm + ' dBm' : null], ['RX Sensitivity', t.rx_sensitivity_dbm != null ? t.rx_sensitivity_dbm + ' dBm' : null],
]); ]);
// SPECIFICATION — Breakout // SPECIFICATION — Breakout
@ -1899,12 +1941,30 @@ async function openTxDetail(id) {
['Year Mainstream', t.year_mainstream], ['Year Mainstream', t.year_mainstream],
]); ]);
// SPECIFICATION — Pricing // SPECIFICATION — Pricing (verified prices from DB)
var prices = t.competitor_prices || [];
if (prices.length > 0) {
h += '<div class="panel-section">Current Prices</div>';
h += '<div class="spec-table">';
prices.forEach(function(p) {
var verBadge = p.is_verified
? '<span style="color:#2d6a4f;font-size:0.7rem;font-weight:600;margin-left:0.4rem">✓ Verified</span>'
: '<span style="color:#888;font-size:0.7rem;margin-left:0.4rem">unverified</span>';
var priceStr = p.currency + ' ' + parseFloat(p.price).toLocaleString('de-DE', {minimumFractionDigits:2,maximumFractionDigits:2});
var dateStr = '<span style="color:#aaa;font-size:0.68rem;margin-left:0.4rem">' + fmtDate(p.observed_at) + '</span>';
var urlLink = p.url ? ' <a href="' + esc(p.url) + '" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.7rem;text-decoration:none;margin-left:0.3rem"></a>' : '';
h += '<div class="spec-row"><span class="spec-label">' + esc(p.vendor_name) + '</span>'
+ '<span class="spec-val">' + priceStr + verBadge + dateStr + urlLink + '</span></div>';
});
h += '</div>';
} else {
// Fallback: show MSRP/street from transceivers table if no price_observations
h += renderSpecTable('Pricing', [ h += renderSpecTable('Pricing', [
['MSRP', t.msrp_usd ? '$' + parseFloat(t.msrp_usd).toLocaleString() : null], ['MSRP', t.msrp_usd ? '$' + parseFloat(t.msrp_usd).toLocaleString() : null],
['Street Price', t.street_price_usd ? '$' + parseFloat(t.street_price_usd).toLocaleString() : null], ['Street Price', t.street_price_usd ? '$' + parseFloat(t.street_price_usd).toLocaleString() : null],
['Price Tier', t.price_tier], ['Price Tier', t.price_tier],
]); ]);
}
// Notes (scraped extra specs) // Notes (scraped extra specs)
if (t.notes) { if (t.notes) {