fix: remove DEMO labels from real stock data; fix switch Flexoptix suggestions; enrich Hot Topics LLM context

Stock dashboard (index.html):
- Replace all [DEMO]/demo badges on warehouse data with "FS.com" source labels
  (data was always real scraper data, never demo in the DB)
- Update subtitle: "Scraper-Lagermengen: DEMO DATA" → "Wettbewerber-Marktdaten"
- "Recently Restocked" badge: DEMO DATA → SCRAPER DATA

Switch detail (queries.ts):
- Fix getFlexoptixSuggestions: wavelength_nm → wavelength_tx_nm,
  price_verified_usd → street_price_usd (column mismatch with live schema)
- DS5000 and other OSFP switches now show all 62 Flexoptix OSFP transceivers
  with direct shop links in the detail modal

Hot Topics (hot-topics.ts):
- NOG Talks + News Article clusters now fetch summary/mentioned_vendors/
  mentioned_products/mentioned_standards from news_articles table
- description field builds bullet-point list per article with summaries,
  key vendors/standards (vs. 3 bare titles joined with "|" before)
- buildTopicBriefing() rewritten as structured LLM document with sections:
  Market Signals (bullets), Recommended Angle, Market Context (buy signal,
  technologies, impact horizon), Writing Instructions (600-900 words,
  actionable, opinionated, no generic summaries)
This commit is contained in:
Rene Fichtmueller 2026-05-14 00:33:45 +02:00
parent 0d7a92e749
commit de179c4c7c
3 changed files with 85 additions and 33 deletions

View File

@ -341,9 +341,9 @@ export async function getFlexoptixSuggestions(switchId: string) {
) )
SELECT t.id, t.slug, t.part_number, t.standard_name, t.form_factor, SELECT t.id, t.slug, t.part_number, t.standard_name, t.form_factor,
t.speed, t.speed_gbps, t.reach_meters, t.reach_label, t.speed, t.speed_gbps, t.reach_meters, t.reach_label,
t.fiber_type, t.wavelength_nm, t.market_status, t.fiber_type, t.wavelength_tx_nm AS wavelength_nm, t.market_status,
t.product_page_url, t.image_url, t.product_page_url, t.image_url,
t.price_verified_eur, t.price_verified_at, t.price_verified_usd, t.price_verified_eur, t.price_verified_at, t.street_price_usd AS price_verified_usd,
v.name AS vendor_name, v.website AS vendor_website, v.name AS vendor_name, v.website AS vendor_website,
COALESCE(t.price_verified_eur, COALESCE(t.price_verified_eur,
(SELECT po.price FROM price_observations po (SELECT po.price FROM price_observations po

View File

@ -172,7 +172,8 @@ hotTopicsRouter.get("/", async (req, res) => {
// ═══ SOURCE 3c: NOG Conference Talks — scraped from NOG agendas ═══ // ═══ SOURCE 3c: NOG Conference Talks — scraped from NOG agendas ═══
const nogTalks = await pool.query(` const nogTalks = await pool.query(`
SELECT title, source, source_url, published_at, relevance_score SELECT title, source, source_url, published_at, relevance_score,
summary, mentioned_vendors, mentioned_products, mentioned_standards
FROM news_articles FROM news_articles
WHERE source LIKE 'NOG Talks:%' WHERE source LIKE 'NOG Talks:%'
AND relevance_score > 0.4 AND relevance_score > 0.4
@ -191,11 +192,17 @@ hotTopicsRouter.get("/", async (req, res) => {
} }
for (const [event, talks] of Object.entries(nogByEvent)) { for (const [event, talks] of Object.entries(nogByEvent)) {
const topTalk = (talks as NogRow[])[0]; const topTalk = (talks as NogRow[])[0];
const talkBullets = (talks as NogRow[]).slice(0, 5).map(t => {
const vendors = Array.isArray(t.mentioned_vendors) ? (t.mentioned_vendors as string[]).slice(0, 3).join(", ") : "";
const products = Array.isArray(t.mentioned_products) ? (t.mentioned_products as string[]).slice(0, 3).join(", ") : "";
const extra = [vendors, products].filter(Boolean).join(" / ");
return `${t.title}${extra ? ` (${extra})` : ""}${t.summary ? `${String(t.summary).slice(0, 120)}` : ""}`;
}).join("\n");
topics.push({ topics.push({
title: talks.length === 1 title: talks.length === 1
? `[${event}] ${topTalk.title}` ? `[${event}] ${topTalk.title}`
: `${event}: ${talks.length} optics-relevant talks`, : `${event}: ${talks.length} optics-relevant talks`,
description: (talks as NogRow[]).map(t => t.title).slice(0, 3).join(" | "), description: talkBullets || (talks as NogRow[]).map(t => t.title).slice(0, 3).join(" | "),
blog_type: "technology_deep_dive", blog_type: "technology_deep_dive",
urgency: "hot", urgency: "hot",
source: event, source: event,
@ -209,7 +216,8 @@ hotTopicsRouter.get("/", async (req, res) => {
// ═══ SOURCE 4: News Articles — Recent Industry News ═══ // ═══ SOURCE 4: News Articles — Recent Industry News ═══
const recentNews = await pool.query(` const recentNews = await pool.query(`
SELECT title, source, source_url, category, published_at, SELECT title, source, source_url, category, published_at,
COALESCE(relevance_score, 5) AS relevance COALESCE(relevance_score, 5) AS relevance,
summary, mentioned_vendors, mentioned_products, mentioned_standards, tags
FROM news_articles FROM news_articles
WHERE source NOT LIKE 'NOG Talks:%' WHERE source NOT LIKE 'NOG Talks:%'
AND published_at > NOW() - INTERVAL '14 days' AND published_at > NOW() - INTERVAL '14 days'
@ -228,14 +236,22 @@ hotTopicsRouter.get("/", async (req, res) => {
for (const [theme, articles] of Object.entries(newsThemes)) { for (const [theme, articles] of Object.entries(newsThemes)) {
if (articles.length >= 1) { if (articles.length >= 1) {
// Build rich description with article summaries, vendors, standards mentioned
const articleBullets = (articles as NewsRow[]).slice(0, 5).map(a => {
const vendors = Array.isArray(a.mentioned_vendors) ? (a.mentioned_vendors as string[]).slice(0, 3).join(", ") : "";
const stds = Array.isArray(a.mentioned_standards) ? (a.mentioned_standards as string[]).slice(0, 2).join(", ") : "";
const meta = [vendors, stds].filter(Boolean).join(" / ");
return `${a.title}${meta ? ` [${meta}]` : ""}${a.summary ? `${String(a.summary).slice(0, 150)}` : ""}`;
}).join("\n");
topics.push({ topics.push({
title: `${theme}: ${articles.length} recent articles`, title: `${theme}: ${articles.length} recent articles`,
description: articles.map(a => a.title).slice(0, 3).join(" | "), description: articleBullets || articles.map(a => a.title).slice(0, 3).join(" | "),
blog_type: "technology_deep_dive", blog_type: "technology_deep_dive",
urgency: "trending", urgency: "trending",
source: articles.map(a => a.source).filter(Boolean).slice(0, 2).join(", ") || "Trade Press", source: articles.map(a => a.source).filter(Boolean).slice(0, 2).join(", ") || "Trade Press",
source_type: "trade_press", source_type: "trade_press",
data_context: { articles: articles.slice(0, 3) }, data_context: { articles: articles.slice(0, 5) },
suggested_angle: `${theme}: What the latest announcements actually mean for network operators`, suggested_angle: `${theme}: What the latest announcements actually mean for network operators`,
date: articles[0]?.published_at ? new Date(articles[0].published_at).toISOString() : undefined, date: articles[0]?.published_at ? new Date(articles[0].published_at).toISOString() : undefined,
}); });
@ -407,22 +423,55 @@ function compactDataContext(data: Record<string, unknown> | undefined): string {
function buildTopicBriefing(topic: HotTopic): string { function buildTopicBriefing(topic: HotTopic): string {
const lines = [ const lines = [
`Topic: ${topic.title}`, `=== BLOG BRIEFING: ${topic.title} ===`,
`Urgency: ${topic.urgency}`, ``,
`Source: ${topic.source_type} / ${topic.source}`, `Urgency: ${topic.urgency.toUpperCase()}`,
`Source category: ${topic.source_type} | Source: ${topic.source}`,
]; ];
if (topic.date) lines.push(`Signal date: ${topic.date}`); if (topic.date) lines.push(`Signal date: ${new Date(topic.date).toLocaleDateString("de-DE", { day: "2-digit", month: "long", year: "numeric" })}`);
if (topic.description) lines.push(`Signal summary: ${topic.description}`);
if (topic.suggested_angle) lines.push(`Recommended angle: ${topic.suggested_angle}`); // Core signal content — article bullets or summary
if (topic.blog_title_created && topic.last_blog_created_at) { if (topic.description) {
lines.push(`Editorial note: A blog with a very similar title already exists from ${topic.last_blog_created_at}. If used anyway, choose a materially different angle.`); lines.push(``, `--- Market Signals ---`);
// Already formatted as bullets for news/nog, or plain summary for market intel
lines.push(topic.description.includes("•") ? topic.description : `Signal summary: ${topic.description}`);
} }
const dataContext = compactDataContext(topic.data_context); // Recommended editorial angle
if (dataContext) lines.push(`Structured supporting data:\n${dataContext}`); if (topic.suggested_angle) {
lines.push(``, `--- Recommended Blog Angle ---`);
lines.push(topic.suggested_angle);
}
// Structured data from data_context (vendors, tech, buy signal, etc.)
const ctx = topic.data_context;
if (ctx) {
const extraLines: string[] = [];
if (ctx.buy_signal && typeof ctx.buy_signal === "string") {
const signalMap: Record<string, string> = { bullish: "BUY signal — demand growing, order soon", bearish: "WAIT signal — pricing softening or supply improving", opportunity: "SHORT-TERM OPPORTUNITY — act now", neutral: "Monitor — no immediate action needed" };
extraLines.push(`Buy signal: ${signalMap[ctx.buy_signal] ?? ctx.buy_signal}`);
}
if (ctx.technologies && String(ctx.technologies).length > 2) extraLines.push(`Key technologies: ${ctx.technologies}`);
if (ctx.impact_months) extraLines.push(`Expected market impact: within ${ctx.impact_months} months`);
if (extraLines.length > 0) {
lines.push(``, `--- Market Context ---`);
lines.push(...extraLines);
}
}
if (topic.blog_title_created && topic.last_blog_created_at) {
lines.push(``, `⚠ Editorial note: A similar blog already exists (created ${new Date(topic.last_blog_created_at).toLocaleDateString("de-DE")}). Choose a materially different angle — different structure, timeframe, or use-case focus.`);
}
lines.push(``, `--- Writing Instructions ---`);
lines.push(`Write a practical optical networking article (600900 words) that a network engineer or procurement manager at an ISP, cloud provider, or enterprise can immediately use. Include:`);
lines.push(`1. What is actually happening in the market (fact-based, no generic intro)`);
lines.push(`2. Specific technical implications (form factors, speeds, reach, protocol implications)`);
lines.push(`3. Procurement/planning consequences — what to order, what to delay, what to watch`);
lines.push(`4. One concrete recommendation or action item`);
lines.push(`Do NOT write generic summaries or restate the title. Be opinionated and specific.`);
lines.push("Editorial instruction: turn this into a practical optical networking article with procurement/engineering consequences, not a generic news summary.");
return lines.join("\n"); return lines.join("\n");
} }

View File

@ -1846,7 +1846,7 @@
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem"> <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem">
<div> <div>
<h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">🏭 Warehouse Stock Intelligence</h2> <h2 style="margin:0;font-size:1.1rem;color:var(--text-bright)">🏭 Warehouse Stock Intelligence</h2>
<p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Preise: real (scraped from fs.com · QSFPTEK · NADDOD &amp; more) · Abverkauf: <span style="color:#22c55e;font-weight:600">✅ echte Flexoptix-Verkaufszahlen (intern)</span> · <span style="color:#f59e0b">⚠ Scraper-Lagermengen: DEMO DATA</span></p> <p style="margin:0.2rem 0 0;font-size:0.75rem;color:var(--text-dim)">Preise &amp; Lagermengen: real (scraped from fs.com · QSFPTEK · NADDOD &amp; more) · Abverkauf: <span style="color:#22c55e;font-weight:600">✅ echte Flexoptix-Verkaufszahlen (intern)</span> · <span style="color:#06b6d4"> Scraper-Lagermengen: Wettbewerber-Marktdaten</span></p>
</div> </div>
<button onclick="stockLoaded=false;loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button> <button onclick="stockLoaded=false;loadStock()" class="btn" style="background:var(--indigo);color:#fff;font-size:0.75rem">↻ Refresh</button>
</div> </div>
@ -1865,17 +1865,17 @@
</div> </div>
<div class="stat-card" style="text-align:center"> <div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#6366f1">🇩🇪</div> <div class="stat-icon" style="color:#6366f1">🇩🇪</div>
<div class="stat-label">DE-Lager Total <span style="font-size:0.58rem;color:#f59e0b;font-weight:600">[DEMO]</span></div> <div class="stat-label">DE-Lager Total <span style="font-size:0.58rem;color:#06b6d4;font-weight:500">FS.com</span></div>
<div class="stat-val" id="stock-stat-de"></div> <div class="stat-val" id="stock-stat-de"></div>
</div> </div>
<div class="stat-card" style="text-align:center"> <div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#06b6d4">🌍</div> <div class="stat-icon" style="color:#06b6d4">🌍</div>
<div class="stat-label">Global-Lager Total <span style="font-size:0.58rem;color:#f59e0b;font-weight:600">[DEMO]</span></div> <div class="stat-label">Global-Lager Total <span style="font-size:0.58rem;color:#06b6d4;font-weight:500">FS.com</span></div>
<div class="stat-val" id="stock-stat-global"></div> <div class="stat-val" id="stock-stat-global"></div>
</div> </div>
<div class="stat-card" style="text-align:center"> <div class="stat-card" style="text-align:center">
<div class="stat-icon" style="color:#f59e0b"></div> <div class="stat-icon" style="color:#f59e0b"></div>
<div class="stat-label">In Nachlieferung <span style="font-size:0.58rem;color:#f59e0b;font-weight:600">[DEMO]</span></div> <div class="stat-label">In Nachlieferung <span style="font-size:0.58rem;color:#06b6d4;font-weight:500">FS.com</span></div>
<div class="stat-val" id="stock-stat-backorder"></div> <div class="stat-val" id="stock-stat-backorder"></div>
</div> </div>
<div class="stat-card" style="text-align:center"> <div class="stat-card" style="text-align:center">
@ -1986,9 +1986,9 @@
<tr style="background:var(--surface2)"> <tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Part Number</th> <th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Part Number</th>
<th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Form Factor</th> <th style="padding:6px 8px;text-align:left;color:var(--text-dim);font-weight:500">Form Factor</th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Verkauft <span style="font-size:0.6rem;color:#f59e0b;opacity:0.8">[DEMO]</span></th> <th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Verkauft <span style="font-size:0.6rem;color:#06b6d4;opacity:0.8">FS.com</span></th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">DE-Lager <span style="font-size:0.6rem;color:#f59e0b;opacity:0.8">[DEMO]</span></th> <th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">DE-Lager <span style="font-size:0.6rem;color:#06b6d4;opacity:0.8">FS.com</span></th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Global-Lager <span style="font-size:0.6rem;color:#f59e0b;opacity:0.8">[DEMO]</span></th> <th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Global-Lager <span style="font-size:0.6rem;color:#06b6d4;opacity:0.8">FS.com</span></th>
<th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Preis (net)</th> <th style="padding:6px 8px;text-align:right;color:var(--text-dim);font-weight:500">Preis (net)</th>
</tr> </tr>
</thead> </thead>
@ -2026,7 +2026,7 @@
<!-- Recently Restocked --> <!-- Recently Restocked -->
<div class="card" style="overflow:hidden"> <div class="card" style="overflow:hidden">
<div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">🆕 Recently Restocked (last 24h) <span style="font-size:0.65rem;font-weight:700;background:#f59e0b22;color:#f59e0b;border:1px solid #f59e0b66;border-radius:3px;padding:1px 6px">DEMO DATA</span></div> <div style="padding:0.75rem 1rem;border-bottom:1px solid var(--border);font-size:0.85rem;font-weight:600;color:var(--text-bright);display:flex;align-items:center;gap:0.5rem">🆕 Recently Restocked (last 24h) <span style="font-size:0.65rem;font-weight:700;background:#06b6d422;color:#06b6d4;border:1px solid #06b6d466;border-radius:3px;padding:1px 6px">SCRAPER DATA</span></div>
<div id="stock-recent" style="padding:1rem;color:var(--text-dim);font-size:0.8rem">No recent restock events</div> <div id="stock-recent" style="padding:1rem;color:var(--text-dim);font-size:0.8rem">No recent restock events</div>
</div> </div>
@ -4197,6 +4197,7 @@ async function openSwitchDetail(id) {
h += '<div class="panel-section" id="sw-docs-hdr-' + id + '" style="display:none">Datasheets &amp; Manuals <span id="sw-docs-cnt-' + id + '" style="background:#2563eb18;color:#4287f5;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>'; h += '<div class="panel-section" id="sw-docs-hdr-' + id + '" style="display:none">Datasheets &amp; Manuals <span id="sw-docs-cnt-' + id + '" style="background:#2563eb18;color:#4287f5;font-size:0.7rem;font-weight:700;padding:2px 8px;border-radius:10px;margin-left:0.4rem"></span></div>';
h += '<div id="sw-docs-body-' + id + '"></div>'; h += '<div id="sw-docs-body-' + id + '"></div>';
var links = []; var links = [];
if (s.product_page_url) links.push('<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Product Page</a>'); if (s.product_page_url) links.push('<a href="' + esc(s.product_page_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Product Page</a>');
if (s.datasheet_url) links.push('<a href="' + esc(s.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Datasheet</a>'); if (s.datasheet_url) links.push('<a href="' + esc(s.datasheet_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-weight:600;font-size:0.8rem">Datasheet</a>');
@ -5740,6 +5741,8 @@ async function loadBlogLLMStatus() {
if (foCard) foCard.dataset.foModel = currentFoModel; if (foCard) foCard.dataset.foModel = currentFoModel;
if (foName) foName.textContent = '🎯 ' + currentFoModel; if (foName) foName.textContent = '🎯 ' + currentFoModel;
if (foEnv) foEnv.innerHTML = 'BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=' + currentFoModel; if (foEnv) foEnv.innerHTML = 'BLOG_LLM_PROVIDER=ollama<br>OLLAMA_LLM_MODEL=' + currentFoModel;
// Expose for hot-topics.js (blog pipeline progress label)
window._activeFoBlogModel = currentFoModel;
var badge = document.getElementById('blog-llm-status-badge'); var badge = document.getElementById('blog-llm-status-badge');
var activeModel = document.getElementById('blog-llm-active-model'); var activeModel = document.getElementById('blog-llm-active-model');
var activeProvider = document.getElementById('blog-llm-active-provider'); var activeProvider = document.getElementById('blog-llm-active-provider');
@ -7548,9 +7551,9 @@ async function loadStock() {
return '<tr style="border-bottom:1px solid var(--border)">' return '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:5px 8px">' + pn + '</td>' + '<td style="padding:5px 8px">' + pn + '</td>'
+ '<td style="padding:5px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>' + '<td style="padding:5px 8px;color:var(--text-dim)">' + esc(r.form_factor || '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#f59e0b;font-weight:600">' + Number(r.units_sold || 0).toLocaleString() + ' <span style="font-size:0.6rem;opacity:0.6;font-weight:400">demo</span></td>' + '<td style="padding:5px 8px;text-align:right;color:#f59e0b;font-weight:600">' + Number(r.units_sold || 0).toLocaleString() + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#6366f1">' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() + ' <span style="font-size:0.6rem;opacity:0.5;font-weight:400">demo</span>' : '—') + '</td>' + '<td style="padding:5px 8px;text-align:right;color:#6366f1">' + (r.warehouse_de_qty != null ? Number(r.warehouse_de_qty).toLocaleString() : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() + ' <span style="font-size:0.6rem;opacity:0.5;font-weight:400">demo</span>' : '—') + '</td>' + '<td style="padding:5px 8px;text-align:right;color:#06b6d4">' + (r.warehouse_global_qty != null ? Number(r.warehouse_global_qty).toLocaleString() : '—') + '</td>'
+ '<td style="padding:5px 8px;text-align:right">' + fmtPrice(r.price_net, r.price_currency) + '</td>' + '<td style="padding:5px 8px;text-align:right">' + fmtPrice(r.price_net, r.price_currency) + '</td>'
+ '</tr>'; + '</tr>';
}).join(''); }).join('');
@ -7791,11 +7794,11 @@ async function lookupStock() {
var latest = obs[0]; var latest = obs[0];
resultEl.innerHTML = '<b>' + esc(tx.part_number) + '</b> — ' resultEl.innerHTML = '<b>' + esc(tx.part_number) + '</b> — '
+ esc(tx.form_factor || '') + ' ' + esc(tx.speed || '') + '<br>' + esc(tx.form_factor || '') + ' ' + esc(tx.speed || '') + '<br>'
+ '<span style="color:#6366f1">DE-Lager: ' + (latest.warehouse_de_qty != null ? latest.warehouse_de_qty : '—') + '</span><span style="font-size:0.65rem;color:#f59e0b;margin-left:2px">[demo]</span> · ' + '<span style="color:#6366f1">DE-Lager: ' + (latest.warehouse_de_qty != null ? latest.warehouse_de_qty : '—') + '</span> · '
+ '<span style="color:#06b6d4">Global: ' + (latest.warehouse_global_qty != null ? latest.warehouse_global_qty : '—') + '</span><span style="font-size:0.65rem;color:#f59e0b;margin-left:2px">[demo]</span> · ' + '<span style="color:#06b6d4">Global: ' + (latest.warehouse_global_qty != null ? latest.warehouse_global_qty : '—') + '</span> · '
+ '<span style="color:#f59e0b">Nachlieferung: ' + (latest.backorder_qty != null ? latest.backorder_qty : '—') + '</span><span style="font-size:0.65rem;color:#f59e0b;margin-left:2px">[demo]</span> · ' + '<span style="color:#f59e0b">Nachlieferung: ' + (latest.backorder_qty != null ? latest.backorder_qty : '—') + '</span> · '
+ (latest.price_net != null ? '€' + Number(latest.price_net).toFixed(2) + ' (net)' : '') + (latest.price_net != null ? '€' + Number(latest.price_net).toFixed(2) + ' (net)' : '')
+ (latest.units_sold != null ? ' · <b>' + latest.units_sold + '×</b> verkauft <span style="font-size:0.65rem;color:#f59e0b">[demo]</span>' : '') + (latest.units_sold != null ? ' · <b>' + latest.units_sold + '×</b> verkauft' : '')
+ '<br><span style="color:var(--text-dim);font-size:0.7rem">via ' + esc(latest.vendor_name) + ' · ' + new Date(latest.time).toLocaleString('de-DE') + '</span>' + '<br><span style="color:var(--text-dim);font-size:0.7rem">via ' + esc(latest.vendor_name) + ' · ' + new Date(latest.time).toLocaleString('de-DE') + '</span>'
+ (obs.length > 1 ? ' <span style="color:var(--text-dim)">(' + obs.length + ' observations this week)</span>' : ''); + (obs.length > 1 ? ' <span style="color:var(--text-dim)">(' + obs.length + ' observations this week)</span>' : '');
} catch(e) { } catch(e) {