From e66c7b9171c8c08da38120490bed15a9b877d222 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Mon, 22 Jun 2026 15:11:51 -0700 Subject: feat: add NewsPage component and enable News tab --- frontend/components/prism/NewsPage.tsx | 709 +++++++++++++++++++++++++++++++++ 1 file changed, 709 insertions(+) create mode 100644 frontend/components/prism/NewsPage.tsx (limited to 'frontend/components/prism/NewsPage.tsx') diff --git a/frontend/components/prism/NewsPage.tsx b/frontend/components/prism/NewsPage.tsx new file mode 100644 index 0000000..b321f31 --- /dev/null +++ b/frontend/components/prism/NewsPage.tsx @@ -0,0 +1,709 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { KPIStrip } from "@/components/prism/KPIStrip"; +import { TickerHeader } from "@/components/prism/TickerHeader"; +import { api } from "@/lib/api"; +import { formatNewsTimestamp } from "@/lib/format"; +import { buildKpis } from "@/lib/overview"; +import type { NewsAggregate, NewsItem, NewsResponse, TickerOverview } from "@/types/api"; + +type NewsState = "loading" | "ready" | "error" | "empty"; +type RangeFilter = "all" | "24h" | "7d" | "30d"; +type SentimentFilter = "all" | "bullish" | "neutral" | "bearish"; + +const SOURCE_TOP_N = 6; + +function buildRangeCutoffs(nowMs: number): Record { + return { + all: null, + "24h": new Date(nowMs - 24 * 60 * 60 * 1000).toISOString(), + "7d": new Date(nowMs - 7 * 24 * 60 * 60 * 1000).toISOString(), + "30d": new Date(nowMs - 30 * 24 * 60 * 60 * 1000).toISOString(), + }; +} + +const SENTIMENT_LABELS: Record = { + all: "All", + bullish: "Bull", + neutral: "Neutral", + bearish: "Bear", +}; + +const RANGE_LABELS: Record = { + all: "All", + "24h": "24H", + "7d": "7D", + "30d": "30D", +}; + +type Props = { + ticker: string; + overview: TickerOverview; + isSaved: boolean; + onToggleWatchlist: () => void; +}; + +function SkeletonBlock({ height, className = "", style }: { height: number; className?: string; style?: React.CSSProperties }) { + return ( +
+ ); +} + +function NewsSkeleton() { + return ( +
+ + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+ ); +} + +function LedeCard({ + ticker, + itemCount, + provider, + aggregate, +}: { + ticker: string; + itemCount: number; + provider: NewsResponse["provider"]; + aggregate: NewsAggregate | null; +}) { + const providerLabel = provider ?? "aggregated"; + + return ( +
+
+
+ Market Tape +
+
+ Headlines for {ticker} +
+
+ 30 days · {itemCount} {itemCount === 1 ? "article" : "articles"} · {providerLabel} +
+
+ + {aggregate && ( +
+
+ Last 7d buzz: {aggregate.buzz_articles_last_week} +
+
+ + + +
+
+ )} +
+ ); +} + +function SentimentChip({ sentiment, label }: { sentiment: SentimentFilter; label: string }) { + const colors: Record = { + bullish: { bg: "var(--positive-bg, #15241A)", color: "var(--positive, #4F8C5E)", border: "1px solid rgba(79, 140, 94, 0.3)" }, + bearish: { bg: "var(--negative-bg, #2A1517)", color: "var(--negative, #B5494B)", border: "1px solid rgba(181, 73, 75, 0.3)" }, + neutral: { bg: "var(--ink-2, #181d26)", color: "var(--fg-3, #8E8676)", border: "1px solid var(--line-2, #2E3645)" }, + all: { bg: "var(--ink-2, #181d26)", color: "var(--fg-3, #8E8676)", border: "1px solid var(--line-2, #2E3645)" }, + }; + + return ( + + {label} + + ); +} + +function FilterChip({ + label, + active, + onClick, + pressed, +}: { + label: string; + active: boolean; + onClick: () => void; + pressed?: boolean; +}) { + return ( + + ); +} + +function FilterBar({ + items, + range, + setRange, + source, + setSource, + sentiment, + setSentiment, + rangeCutoff, +}: { + items: NewsItem[]; + range: RangeFilter; + setRange: (r: RangeFilter) => void; + source: string; + setSource: (s: string) => void; + sentiment: SentimentFilter; + setSentiment: (s: SentimentFilter) => void; + rangeCutoff: Record; +}) { + const [dropdownOpen, setDropdownOpen] = useState(false); + + const sourceCounts = useMemo(() => { + const counts = new Map(); + items.forEach((item) => { + counts.set(item.source, (counts.get(item.source) ?? 0) + 1); + }); + return Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); + }, [items]); + + const topSources = sourceCounts.slice(0, SOURCE_TOP_N); + const tailSources = sourceCounts.slice(SOURCE_TOP_N); + const isTailSourceSelected = tailSources.some(([s]) => s === source); + + const filtered = items.filter((item) => { + const cutoff = rangeCutoff[range]; + if (cutoff != null && item.published_at < cutoff) return false; + if (source !== "all" && item.source !== source) return false; + if (sentiment !== "all" && item.sentiment !== sentiment) return false; + return true; + }); + + const sentimentCounts = { + bullish: filtered.filter((i) => i.sentiment === "bullish").length, + neutral: filtered.filter((i) => i.sentiment === "neutral").length, + bearish: filtered.filter((i) => i.sentiment === "bearish").length, + }; + + const distinctSources = new Set(filtered.map((i) => i.source)).size; + + const ranges: RangeFilter[] = ["all", "24h", "7d", "30d"]; + const sentiments: SentimentFilter[] = ["all", "bullish", "neutral", "bearish"]; + + return ( +
+
+
+ + Range + + {ranges.map((r) => ( + setRange(r)} /> + ))} +
+ +
+ + Source + + setSource("all")} /> + {topSources.map(([src, count]) => ( + setSource(src)} + /> + ))} + {tailSources.length > 0 && ( +
+ + {dropdownOpen && ( +
+ {tailSources.map(([src, count]) => ( + + ))} +
+ )} +
+ )} +
+ +
+ + Sentiment + + {sentiments.map((s) => ( + setSentiment(s)} + /> + ))} +
+
+ +
+ {filtered.length} {filtered.length === 1 ? "article" : "articles"} · {sentimentCounts.bullish} bull · {sentimentCounts.neutral} neutral · {sentimentCounts.bearish} bear · {distinctSources} {distinctSources === 1 ? "source" : "sources"} +
+
+ ); +} + +function NewsCard({ item }: { item: NewsItem }) { + const timestamp = formatNewsTimestamp(item.published_at); + + return ( + +
+ {item.title} +
+ {item.summary && ( +
+ {item.summary} +
+ )} +
+ + {timestamp.display} + + · + + {item.source} + +
+ + + +
+
+
+ ); +} + +export function NewsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) { + const [data, setData] = useState(null); + const [state, setState] = useState("loading"); + const [range, setRange] = useState("all"); + const [source, setSource] = useState("all"); + const [sentiment, setSentiment] = useState("all"); + const [rangeCutoff, setRangeCutoff] = useState>(() => buildRangeCutoffs(Date.now())); + const kpis = useMemo(() => buildKpis(overview), [overview]); + + useEffect(() => { + let cancelled = false; + setState("loading"); + setData(null); + setRange("all"); + setSource("all"); + setSentiment("all"); + + api + .news(ticker) + .then((res) => { + if (cancelled) return; + setRangeCutoff(buildRangeCutoffs(Date.now())); + if (!res.items.length) { + setData(res); + setState("empty"); + } else { + setData(res); + setState("ready"); + } + }) + .catch(() => { + if (!cancelled) setState("error"); + }); + + return () => { + cancelled = true; + }; + }, [ticker]); + + const filteredItems = useMemo(() => { + if (!data) return []; + return data.items.filter((item) => { + const cutoff = rangeCutoff[range]; + if (cutoff != null && item.published_at < cutoff) return false; + if (source !== "all" && item.source !== source) return false; + if (sentiment !== "all" && item.sentiment !== sentiment) return false; + return true; + }); + }, [data, range, source, sentiment, rangeCutoff]); + + return ( +
+ + + +
+ {state === "loading" && } + + {state === "error" && ( +
+
+ Failed to load news +
+
+ )} + + {state === "empty" && data && ( +
+
+ No recent news for {ticker} +
+
+ )} + + {state === "ready" && data && ( + <> + + + {filteredItems.length === 0 ? ( +
+
+ No matches +
+
+ Try widening the date range or clearing filters. +
+
+ ) : ( +
+ {filteredItems.map((item) => ( + + ))} +
+ )} + + )} +
+ + +
+ ); +} -- cgit v1.3-2-g0d8e