aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-05-16 00:15:35 -0700
committerTyler <tyler@tylerhoang.xyz>2026-05-16 00:15:35 -0700
commit07de8ca5cc62727f52b1be867f00721890b17fce (patch)
tree3db2225425c78fe4fabe84913beb7fe35eda6b1f /components
parentf3181e8b27f910d50453de9ad5f8d967014edf23 (diff)
Redesign news tab UI surface
Diffstat (limited to 'components')
-rw-r--r--components/news.py445
1 files changed, 374 insertions, 71 deletions
diff --git a/components/news.py b/components/news.py
index cb43ea8..90f0ddb 100644
--- a/components/news.py
+++ b/components/news.py
@@ -1,29 +1,36 @@
-"""News feed with sentiment badges."""
-import streamlit as st
-from datetime import datetime
-from services.news_service import get_company_news, get_news_sentiment
+"""News tab rendered as a client-side HTML surface."""
+from datetime import date as _date
+from datetime import datetime as _dt
+from html import escape as _esc
+
+import streamlit.components.v1 as components
+
+from services.data_service import get_company_info, get_latest_price
from services.fmp_service import get_company_news as get_fmp_news
-from utils.security import escape_html, validate_outbound_url
+from services.news_service import get_company_news, get_news_sentiment
+from utils.security import validate_outbound_url
+_XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"}
-def _sentiment_badge(sentiment: str) -> str:
- badges = {
- "bullish": "๐ŸŸข Bullish (heuristic)",
- "bearish": "๐Ÿ”ด Bearish (heuristic)",
- "neutral": "โšช Neutral (heuristic)",
- }
- return badges.get(sentiment.lower(), "โšช Neutral (heuristic)")
+
+def _safe_float(value):
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return None
def _classify_sentiment(article: dict) -> str:
- """Classify sentiment from Finnhub article data."""
- # Finnhub doesn't return per-article sentiment, use headline heuristics
- positive = ["beats", "surges", "rises", "gains", "profit", "record", "upgrade",
- "buy", "outperform", "growth", "strong", "higher", "rally"]
- negative = ["misses", "falls", "drops", "loss", "cut", "downgrade", "sell",
- "underperform", "weak", "lower", "decline", "warning", "layoff"]
+ positive = [
+ "beats", "surges", "rises", "gains", "profit", "record", "upgrade", "buy", "outperform",
+ "growth", "strong", "higher", "rally", "raises", "expands", "upside",
+ ]
+ negative = [
+ "misses", "falls", "drops", "loss", "cut", "downgrade", "sell", "underperform", "weak",
+ "lower", "decline", "warning", "layoff", "lawsuit", "probe", "cuts",
+ ]
headline = (article.get("headline") or article.get("title") or "").lower()
- summary = (article.get("summary") or "").lower()
+ summary = (article.get("summary") or article.get("text") or "").lower()
text = headline + " " + summary
pos = sum(1 for w in positive if w in text)
neg = sum(1 for w in negative if w in text)
@@ -34,66 +41,362 @@ def _classify_sentiment(article: dict) -> str:
return "neutral"
-def _fmt_time(timestamp) -> str:
+def _normalize_dt(raw):
+ if raw is None:
+ return None
+ if isinstance(raw, _dt):
+ return raw
+ if isinstance(raw, _date):
+ return _dt(raw.year, raw.month, raw.day)
+ if isinstance(raw, (int, float)):
+ try:
+ return _dt.utcfromtimestamp(float(raw))
+ except Exception:
+ return None
+
+ text = str(raw).strip()
+ if not text:
+ return None
+
+ norm = text.replace("Z", "+00:00")
try:
- if isinstance(timestamp, (int, float)):
- return datetime.utcfromtimestamp(timestamp).strftime("%b %d, %Y")
- return str(timestamp)[:10]
+ dt = _dt.fromisoformat(norm)
+ if dt.tzinfo is not None:
+ return dt.astimezone().replace(tzinfo=None)
+ return dt
except Exception:
- return ""
+ pass
+
+ for fmt in (
+ "%Y-%m-%d",
+ "%Y-%m-%d %H:%M:%S",
+ "%Y-%m-%dT%H:%M:%S",
+ "%Y-%m-%dT%H:%M:%S.%f",
+ "%Y-%m-%d %H:%M",
+ "%m/%d/%Y",
+ ):
+ try:
+ return _dt.strptime(text[: len(fmt)], fmt)
+ except Exception:
+ pass
+
+ if len(text) >= 10:
+ try:
+ return _dt.strptime(text[:10], "%Y-%m-%d")
+ except Exception:
+ pass
+
+ return None
def render_news(ticker: str):
- # Overall sentiment summary from Finnhub
- sentiment_data = get_news_sentiment(ticker)
- if sentiment_data:
- buzz = sentiment_data.get("buzz", {})
- score = sentiment_data.get("sentiment", {})
- col1, col2, col3 = st.columns(3)
- col1.metric("Articles (7d)", buzz.get("articlesInLastWeek", "โ€”"))
- bull_pct = score.get("bullishPercent")
- bear_pct = score.get("bearishPercent")
- col2.metric("Bullish %", f"{bull_pct * 100:.1f}%" if bull_pct is not None else "โ€”")
- col3.metric("Bearish %", f"{bear_pct * 100:.1f}%" if bear_pct is not None else "โ€”")
- st.caption("Sentiment tags below are rule-based headline heuristics, not model-scored article sentiment.")
- st.divider()
+ import json as _json
- # Fetch articles โ€” Finnhub first, FMP as fallback
- articles = get_company_news(ticker)
- if not articles:
- articles = get_fmp_news(ticker)
+ info = get_company_info(ticker) or {}
+ price = get_latest_price(ticker)
+ sentiment_data = get_news_sentiment(ticker) or {}
- if not articles:
- st.info("No recent news found.")
- return
+ # Finnhub first, FMP fallback.
+ raw_articles = get_company_news(ticker) or []
+ provider = "finnhub"
+ if not raw_articles:
+ raw_articles = get_fmp_news(ticker) or []
+ provider = "fmp"
- for article in articles:
- headline = article.get("headline") or article.get("title", "No title")
- source = article.get("source") or article.get("site", "")
- url = validate_outbound_url(article.get("url") or article.get("newsURL"))
- timestamp = article.get("datetime") or article.get("publishedDate", "")
- summary = article.get("summary") or article.get("text") or ""
- headline_html = escape_html(headline)
+ rows = []
+ for i, article in enumerate(raw_articles):
+ article = article or {}
+ title = str(article.get("headline") or article.get("title") or "").strip()
+ if not title:
+ title = "Untitled item"
+ source = str(article.get("source") or article.get("site") or "").strip() or "Unknown source"
+ summary = str(article.get("summary") or article.get("text") or "").strip()
+ dt_obj = _normalize_dt(article.get("datetime") or article.get("publishedDate"))
+ ts = int(dt_obj.timestamp()) if dt_obj else None
+ date_iso = dt_obj.strftime("%Y-%m-%d") if dt_obj else None
+ date_pretty = dt_obj.strftime("%b %d, %Y") if dt_obj else "โ€”"
+ time_pretty = dt_obj.strftime("%H:%M") if dt_obj else "โ€”"
+ url = validate_outbound_url(article.get("url") or article.get("newsURL"))
sentiment = _classify_sentiment(article)
- badge = _sentiment_badge(sentiment)
- time_str = _fmt_time(timestamp)
- with st.container():
- col1, col2 = st.columns([5, 1])
- with col1:
- if url:
- st.markdown(
- f'<strong><a href="{escape_html(url)}" target="_blank" rel="noopener noreferrer">{headline_html}</a></strong>',
- unsafe_allow_html=True,
- )
- else:
- st.markdown(f"<strong>{headline_html}</strong>", unsafe_allow_html=True)
- meta = " ยท ".join(filter(None, [source, time_str]))
- if meta:
- st.caption(meta)
- if summary:
- st.caption(summary[:200] + ("โ€ฆ" if len(summary) > 200 else ""))
- with col2:
- st.markdown(badge)
- st.divider()
+ rows.append(
+ {
+ "id": i,
+ "title": title,
+ "summary": summary,
+ "source": source,
+ "url": url,
+ "sentiment": sentiment,
+ "date": date_iso,
+ "date_pretty": date_pretty,
+ "time_pretty": time_pretty,
+ "ts": ts,
+ }
+ )
+
+ rows.sort(key=lambda r: (r.get("ts") is not None, r.get("ts") or 0), reverse=True)
+
+ buzz = sentiment_data.get("buzz") or {}
+ score = sentiment_data.get("sentiment") or {}
+ bull_pct = score.get("bullishPercent")
+ bear_pct = score.get("bearishPercent")
+ weekly_articles = buzz.get("articlesInLastWeek")
+
+ bull_str = f"{float(bull_pct) * 100:.1f}%" if _safe_float(bull_pct) is not None else "โ€”"
+ bear_str = f"{float(bear_pct) * 100:.1f}%" if _safe_float(bear_pct) is not None else "โ€”"
+
+ cur_num = _safe_float(info.get("currentPrice") or info.get("regularMarketPrice") or price)
+ prev_num = _safe_float(info.get("previousClose"))
+ if cur_num is not None and prev_num is not None and prev_num > 0:
+ chg_pct = (cur_num - prev_num) / prev_num * 100.0
+ chg_sign = "+" if chg_pct >= 0 else ""
+ chg_arrow = "โ–ฒ" if chg_pct >= 0 else "โ–ผ"
+ chg_str = chg_arrow + " " + chg_sign + "{:.2f}%".format(chg_pct)
+ chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg"
+ else:
+ chg_str = "โ€”"
+ chg_cls = ""
+
+ exchange_raw = str(info.get("exchange") or "")
+ exchange = _XMAP.get(exchange_raw, exchange_raw) or "โ€”"
+ co_name = _esc(info.get("longName") or info.get("shortName") or ticker.upper())
+ price_str = "${:,.2f}".format(cur_num) if cur_num is not None else "โ€”"
+
+ rows_js = "const NEWS_ROWS=" + _json.dumps(rows) + ";"
+
+ n_rows = max(len(rows), 18)
+ height = 1240 + n_rows * 28
+
+ _ROOT = (
+ "<style>*,*::before,*::after{box-sizing:border-box}"
+ ":root{"
+ "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;"
+ "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;"
+ "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;"
+ "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A;"
+ "--oxford:#1F3D5C;--oxford-light:#2E5A87;"
+ "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;"
+ "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;"
+ "--font-display:'EB Garamond',Georgia,serif;"
+ "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;"
+ "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;"
+ "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;"
+ "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;"
+ "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;"
+ "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;"
+ "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;"
+ "}"
+ "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);"
+ "font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}"
+ "</style>"
+ )
+
+ fonts_link = (
+ "<link rel='preconnect' href='https://fonts.googleapis.com'>"
+ "<link href='https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500"
+ "&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'"
+ " rel='stylesheet'>"
+ )
+
+ _NEWS_CSS = """<style>
+.nw-wrap{background:var(--ink-0);min-height:100vh}
+.num{font-family:var(--font-mono);font-variant-numeric:tabular-nums}
+.eyebrow-lbl{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600}
+.val-ctx{display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--sp-5);border-bottom:1px solid var(--line-1);background:var(--ink-1)}
+.val-ctx .sym{font-family:var(--font-display);font-size:var(--fs-24);font-weight:500;letter-spacing:var(--tr-tight)}
+.val-ctx .name{font-family:var(--font-display);font-style:italic;font-size:var(--fs-16);color:var(--fg-2);margin-left:calc(-1 * var(--sp-1));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:48ch}
+.val-ctx .eyebrow-ctx{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap}
+.val-ctx .meta{display:flex;gap:var(--sp-4);margin-left:auto;font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)}
+.val-ctx .meta span{white-space:nowrap}
+.val-ctx .meta .px{color:var(--fg-1);font-size:var(--fs-14)}
+.val-ctx .meta .chg-pos{color:var(--positive)}
+.val-ctx .meta .chg-neg{color:var(--negative)}
+.nw-body{padding:var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-5)}
+.nw-lede{display:grid;grid-template-columns:1.6fr 1fr;gap:var(--sp-5);align-items:stretch;background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-5)}
+.nw-lede .left{display:flex;flex-direction:column;gap:var(--sp-2)}
+.nw-lede .ttl{font-family:var(--font-display);font-size:var(--fs-30);font-weight:500;letter-spacing:var(--tr-tight);line-height:1.1;color:var(--fg-1);margin:var(--sp-1) 0 0}
+.nw-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:68ch;margin:0}
+.nw-lede .right{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);align-content:end}
+.kr-source{display:flex;flex-direction:column;gap:2px;padding:var(--sp-3) var(--sp-4);background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2)}
+.kr-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600}
+.kr-source .v{font-family:var(--font-mono);font-size:var(--fs-14);font-variant-numeric:tabular-nums;color:var(--fg-1);font-weight:500}
+.nw-controls{display:grid;grid-template-columns:1fr 1fr 1fr;gap:var(--sp-4);padding:var(--sp-3) var(--sp-4);background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3)}
+.ctl-group{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap}
+.ctl-lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:var(--tr-wider);text-transform:uppercase;color:var(--fg-4);margin-right:var(--sp-2)}
+.ctl-btn{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;padding:5px 14px;border-radius:var(--r-2);border:1px solid var(--line-2);background:var(--ink-2);color:var(--fg-3);cursor:pointer;letter-spacing:var(--tr-wide);transition:background .15s ease,color .15s ease,border-color .15s ease}
+.ctl-btn:hover{background:var(--ink-3);color:var(--fg-2)}
+.ctl-btn.active{background:var(--ink-3);color:var(--brass);border-color:var(--brass-deep)}
+.nw-kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:var(--sp-3)}
+.nw-kpi{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-1)}
+.nw-kpi .lbl{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:500}
+.nw-kpi .v{font-family:var(--font-mono);font-size:var(--fs-24);line-height:1.1;font-variant-numeric:tabular-nums;color:var(--fg-1)}
+.nw-table-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden}
+.nw-table-head{padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--line-1);font-family:var(--font-sans);font-size:var(--fs-12);font-weight:600;letter-spacing:var(--tr-wider);text-transform:uppercase;color:var(--fg-3)}
+.nw-grid-head,.nw-grid-row{display:grid;grid-template-columns:0.8fr 1.1fr 3.2fr 1.4fr 0.7fr}
+.nw-grid-head{background:var(--ink-2);border-bottom:1px solid var(--line-1)}
+.nw-grid-head div{padding:8px var(--sp-3);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600}
+.nw-grid-row{border-bottom:1px solid var(--line-1);transition:background .15s ease}
+.nw-grid-row:last-child{border-bottom:none}
+.nw-grid-row:hover{background:rgba(194,170,122,0.04)}
+.nw-grid-row .date{font-family:var(--font-mono);font-variant-numeric:tabular-nums;color:var(--fg-2);white-space:nowrap}
+.nw-grid-row .src{font-family:var(--font-sans);color:var(--fg-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.nw-grid-row .title{display:flex;flex-direction:column;gap:6px;min-width:0}
+.nw-grid-row .title a,.nw-grid-row .title .t{font-family:var(--font-sans);font-size:var(--fs-14);font-weight:500;line-height:1.45;color:var(--fg-1);text-decoration:none}
+.nw-grid-row .title a:hover{color:var(--brass-bright)}
+.nw-grid-row .summary{font-family:var(--font-sans);font-size:var(--fs-12);line-height:1.45;color:var(--fg-3);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.sent{display:inline-flex;align-items:center;justify-content:center;padding:2px 8px;border-radius:var(--r-full);font-family:var(--font-mono);font-size:11px;font-weight:500}
+.sent.bull{background:var(--positive-bg);color:var(--positive)}
+.sent.bear{background:var(--negative-bg);color:var(--negative)}
+.sent.neu{background:var(--ink-2);color:var(--fg-3);border:1px solid var(--line-2)}
+.nw-open{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;color:var(--brass);text-decoration:none;border-bottom:1px solid var(--brass-deep)}
+.nw-open:hover{color:var(--brass-bright)}
+.nw-empty{padding:var(--sp-7);text-align:center;color:var(--fg-3);font-size:var(--fs-14);font-family:var(--font-sans);line-height:1.6}
+.va-foot{font-family:var(--font-sans);font-size:var(--fs-12);color:var(--fg-3);line-height:1.6;padding:var(--sp-3) var(--sp-5);border:1px solid var(--line-1);border-radius:var(--r-2);background:var(--ink-1)}
+@media (max-width:1100px){
+ .nw-lede{grid-template-columns:1fr}
+ .nw-lede .right{grid-template-columns:1fr}
+ .nw-controls{grid-template-columns:1fr}
+ .nw-kpis{grid-template-columns:1fr 1fr}
+ .nw-grid-head,.nw-grid-row{grid-template-columns:1fr 1.2fr 2.5fr 1fr 0.8fr}
+}
+</style>"""
+
+ ctx_html = (
+ '<div class="val-ctx">'
+ + '<span class="sym">'
+ + _esc(ticker.upper())
+ + "</span>"
+ + '<span class="name">'
+ + co_name
+ + "</span>"
+ + '<span class="eyebrow-ctx" style="margin-left:12px">News</span>'
+ + '<div class="meta">'
+ + "<span>"
+ + _esc(exchange)
+ + "</span>"
+ + '<span class="px num">'
+ + price_str
+ + "</span>"
+ + '<span class="'
+ + chg_cls
+ + ' num">'
+ + chg_str
+ + "</span>"
+ + "</div></div>"
+ )
+
+ lede_html = (
+ '<section class="nw-lede">'
+ + '<div class="left">'
+ + '<span class="eyebrow-lbl">Coverage</span>'
+ + '<div class="ttl">What the tape is saying</div>'
+ + '<p class="sub">Headline flow for '
+ + _esc(ticker.upper())
+ + '. Read source mix, timing, and sentiment tags in one stream. Sentiment chips are rule-based heuristics from headline and summary language.</p>'
+ + "</div>"
+ + '<div class="right">'
+ + '<div class="kr-source"><span class="lbl">Primary feed</span><span class="v">'
+ + _esc(provider)
+ + "</span></div>"
+ + '<div class="kr-source"><span class="lbl">Articles (7d)</span><span class="v num">'
+ + (str(weekly_articles) if weekly_articles is not None else "โ€”")
+ + "</span></div>"
+ + '<div class="kr-source"><span class="lbl">Loaded rows</span><span class="v num">'
+ + str(len(rows))
+ + "</span></div>"
+ + "</div></section>"
+ )
+
+ controls_html = (
+ '<div class="nw-controls">'
+ + '<div class="ctl-group">'
+ + '<span class="ctl-lbl">Range</span>'
+ + '<button class="ctl-btn active" onclick="setRange(\'all\',this)">All</button>'
+ + '<button class="ctl-btn" onclick="setRange(\'24h\',this)">24H</button>'
+ + '<button class="ctl-btn" onclick="setRange(\'7d\',this)">7D</button>'
+ + '<button class="ctl-btn" onclick="setRange(\'30d\',this)">30D</button>'
+ + "</div>"
+ + '<div class="ctl-group" id="nw-source-buttons"><span class="ctl-lbl">Source</span></div>'
+ + '<div class="ctl-group">'
+ + '<span class="ctl-lbl">Sentiment</span>'
+ + '<button class="ctl-btn active" onclick="setSentiment(\'all\',this)">All</button>'
+ + '<button class="ctl-btn" onclick="setSentiment(\'bullish\',this)">Bullish</button>'
+ + '<button class="ctl-btn" onclick="setSentiment(\'neutral\',this)">Neutral</button>'
+ + '<button class="ctl-btn" onclick="setSentiment(\'bearish\',this)">Bearish</button>'
+ + "</div></div>"
+ )
+
+ foot_html = (
+ '<div class="va-foot">'
+ + "<span>News feed sourced from Finnhub (with FMP fallback) ยท Sentiment chips are heuristic labels, not model-scored article sentiment ยท Outbound links are validated before render</span>"
+ + "</div>"
+ )
+
+ js = (
+ "<script>"
+ + rows_js
+ + "var activeRange='all';var activeSent='all';var activeSource='all';"
+ + "function esc(s){if(s===null||s===undefined)return '';return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}"
+ + "function escAttr(s){return esc(s).replace(/\\\"/g,'&quot;').replace(/'/g,'&#39;');}"
+ + "function relAge(ts){if(!ts)return 'โ€”';var now=Math.floor(Date.now()/1000);var d=Math.max(0,now-ts);if(d<3600)return Math.floor(d/60)+'m ago';if(d<86400)return Math.floor(d/3600)+'h ago';if(d<86400*7)return Math.floor(d/86400)+'d ago';return Math.floor(d/(86400*7))+'w ago';}"
+ + "function sourceList(){var s={};(NEWS_ROWS||[]).forEach(function(r){if(r.source)s[r.source]=1;});return Object.keys(s).sort(function(a,b){return a.localeCompare(b);});}"
+ + "function buildSourceButtons(){var host=document.getElementById('nw-source-buttons');if(!host)return;host.innerHTML='<span class=\"ctl-lbl\">Source</span>';"
+ + "var all=document.createElement('button');all.className='ctl-btn active';all.textContent='All';all.onclick=function(){setSource('all',all);};host.appendChild(all);"
+ + "sourceList().slice(0,8).forEach(function(src){var b=document.createElement('button');b.className='ctl-btn';b.textContent=src;b.onclick=function(){setSource(src,b);};host.appendChild(b);});}"
+ + "function setBtnState(group,btn){var root=btn&&btn.parentElement; if(!root)return; root.querySelectorAll('.ctl-btn').forEach(function(x){x.classList.remove('active');});btn.classList.add('active');}"
+ + "function cutoff(range){var now=Math.floor(Date.now()/1000);if(range==='24h')return now-86400;if(range==='7d')return now-86400*7;if(range==='30d')return now-86400*30;return null;}"
+ + "function filtered(){var c=cutoff(activeRange);return (NEWS_ROWS||[]).filter(function(r){if(c&&(!r.ts||r.ts<c))return false;if(activeSent!=='all'&&r.sentiment!==activeSent)return false;if(activeSource!=='all'&&r.source!==activeSource)return false;return true;});}"
+ + "function setRange(v,btn){activeRange=v;setBtnState('range',btn);renderAll();}"
+ + "function setSentiment(v,btn){activeSent=v;setBtnState('sent',btn);renderAll();}"
+ + "function setSource(v,btn){activeSource=v;setBtnState('source',btn);renderAll();}"
+ + "function sentimentClass(s){if(s==='bullish')return 'bull';if(s==='bearish')return 'bear';return 'neu';}"
+ + "function sentimentLabel(s){if(s==='bullish')return 'Bullish';if(s==='bearish')return 'Bearish';return 'Neutral';}"
+ + "function renderKPIs(rows){var m={bullish:0,bearish:0,neutral:0};rows.forEach(function(r){m[r.sentiment]=(m[r.sentiment]||0)+1;});"
+ + "var src={};rows.forEach(function(r){src[r.source]=1;});var srcCount=Object.keys(src).length;"
+ + "var newest='โ€”';if(rows.length&&rows[0].date_pretty){newest=rows[0].date_pretty+' ยท '+rows[0].time_pretty;}"
+ + "var html='';"
+ + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Filtered Items</div><div class=\"v\">'+rows.length.toLocaleString()+'</div></div>';"
+ + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Bullish</div><div class=\"v\">'+(m.bullish||0).toLocaleString()+'</div></div>';"
+ + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Neutral</div><div class=\"v\">'+(m.neutral||0).toLocaleString()+'</div></div>';"
+ + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Bearish</div><div class=\"v\">'+(m.bearish||0).toLocaleString()+'</div></div>';"
+ + "html+='<div class=\"nw-kpi\"><div class=\"lbl\">Active Sources</div><div class=\"v\">'+srcCount.toLocaleString()+'</div><div class=\"lbl\" style=\"text-transform:none;letter-spacing:0;color:var(--fg-3)\">'+esc(newest)+'</div></div>';"
+ + "document.getElementById('nw-kpis').innerHTML=html;}"
+ + "function rowHtml(r){var link='โ€”';if(r.url){link='<a class=\"nw-open\" href=\"'+escAttr(r.url)+'\" target=\"_blank\" rel=\"noopener noreferrer\">Open</a>';};"
+ + "var ttl=r.url?('<a href=\"'+escAttr(r.url)+'\" target=\"_blank\" rel=\"noopener noreferrer\">'+esc(r.title)+'</a>'):('<span class=\"t\">'+esc(r.title)+'</span>');"
+ + "var summary=r.summary?('<div class=\"summary\">'+esc(r.summary)+'</div>'):'';"
+ + "return '<div class=\"nw-grid-row\">'"
+ + "+'<div class=\"date\">'+esc(r.date_pretty||'โ€”')+'<br><span style=\"color:var(--fg-4)\">'+esc(relAge(r.ts))+'</span></div>'"
+ + "+'<div class=\"src\">'+esc(r.source||'Unknown source')+'</div>'"
+ + "+'<div class=\"title\">'+ttl+summary+'</div>'"
+ + "+'<div><span class=\"sent '+sentimentClass(r.sentiment)+'\">'+sentimentLabel(r.sentiment)+'</span></div>'"
+ + "+'<div>'+link+'</div>'"
+ + "+'</div>';}"
+ + "function renderTable(rows){var head='<div class=\"nw-table-head\">Recent coverage</div><div class=\"nw-grid-head\"><div>Date</div><div>Source</div><div>Headline</div><div>Tone</div><div>Link</div></div>';"
+ + "if(!rows.length){document.getElementById('nw-table').innerHTML=head+'<div class=\"nw-empty\">No news items match the current filters.<br>Try widening the date range or switching source and sentiment filters.</div>';return;}"
+ + "var body='';rows.forEach(function(r){body+=rowHtml(r);});document.getElementById('nw-table').innerHTML=head+body;}"
+ + "function renderAll(){var rows=filtered();rows.sort(function(a,b){return (b.ts||0)-(a.ts||0);});renderKPIs(rows);renderTable(rows);}"
+ + "function boot(){buildSourceButtons();renderAll();}"
+ + "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',boot);}else{boot();}"
+ + "</script>"
+ )
+
+ doc = (
+ "<!doctype html><html><head><meta charset='utf-8'>"
+ + fonts_link
+ + _ROOT
+ + _NEWS_CSS
+ + "</head><body><div class='nw-wrap'>"
+ + ctx_html
+ + '<div class="nw-body">'
+ + lede_html
+ + controls_html
+ + '<div id="nw-kpis" class="nw-kpis"></div>'
+ + '<div class="nw-table-card"><div id="nw-table"></div></div>'
+ + foot_html
+ + "</div>"
+ + js
+ + "</div></body></html>"
+ )
+
+ components.html(doc, height=height, scrolling=False)