"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) => ( ))}
)} )}
); }