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/app/page.tsx | 8 + frontend/components/prism/NewsPage.tsx | 709 +++++++++++++++++++++++++++++++++ frontend/lib/api.ts | 7 +- frontend/lib/format.ts | 35 ++ frontend/lib/overview.ts | 2 +- frontend/types/api.ts | 24 ++ 6 files changed, 783 insertions(+), 2 deletions(-) create mode 100644 frontend/components/prism/NewsPage.tsx diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e7a4401..6851d91 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -7,6 +7,7 @@ import { AppShell } from "@/components/prism/AppShell"; import { FinancialsPage } from "@/components/prism/FinancialsPage"; import { InsidersPage } from "@/components/prism/InsidersPage"; import { FilingsPage } from "@/components/prism/FilingsPage"; +import { NewsPage } from "@/components/prism/NewsPage"; import { ValuationPage } from "@/components/prism/ValuationPage"; import { OptionsPage } from "@/components/prism/options/OptionsPage"; import { ChartCard } from "@/components/prism/ChartCard"; @@ -362,6 +363,13 @@ function OverviewClient() { isSaved={isSaved} onToggleWatchlist={addOrRemoveCurrentTicker} /> + ) : tab === "news" ? ( + ) : ( <> 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) => ( + + ))} +
+ )} + + )} +
+ + +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 13c73bf..2635af5 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,4 +1,4 @@ -import type { FilingsResponse, FinancialsResponse, HistoryPoint, InsidersResponse, MarketIndex, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api"; +import type { FilingsResponse, FinancialsResponse, HistoryPoint, InsidersResponse, MarketIndex, NewsResponse, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api"; const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").trim().replace(/\/$/, ""); @@ -74,4 +74,9 @@ export const api = { `/api/tickers/${encodeURIComponent(symbol)}/filings` ); }, + news(symbol: string) { + return request( + `/api/tickers/${encodeURIComponent(symbol)}/news` + ); + }, }; diff --git a/frontend/lib/format.ts b/frontend/lib/format.ts index 34bffd8..6a1fcc2 100644 --- a/frontend/lib/format.ts +++ b/frontend/lib/format.ts @@ -29,3 +29,38 @@ export function deltaClass(value?: number | null): string { if (value === null || value === undefined) return "neutral"; return value >= 0 ? "positive" : "negative"; } + +export function formatNewsTimestamp(iso: string): { display: string; iso: string } { + const date = new Date(iso); + const now = new Date(); + const tz: Intl.DateTimeFormatOptions = { timeZone: "America/Los_Angeles" }; + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMs / 3_600_000); + + if (diffMins < 1) { + return { display: "Just now", iso }; + } + if (diffMins < 60) { + return { display: `${diffMins}m ago`, iso }; + } + if (diffHours < 24) { + return { display: `${diffHours}h ago`, iso }; + } + + const inTz = (options: Intl.DateTimeFormatOptions) => + date.toLocaleString("en-US", { ...options, ...tz }); + + if (diffHours < 48) { + const time = inTz({ hour: "numeric", minute: "2-digit" }); + return { display: `Yesterday ${time}`, iso }; + } + + const yearNow = Number(now.toLocaleString("en-US", { year: "numeric", ...tz })); + const yearDate = Number(inTz({ year: "numeric" })); + + if (yearDate === yearNow) { + return { display: inTz({ month: "short", day: "numeric" }), iso }; + } + return { display: inTz({ month: "short", day: "numeric", year: "numeric" }), iso }; +} diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts index 62995d6..a196dd5 100644 --- a/frontend/lib/overview.ts +++ b/frontend/lib/overview.ts @@ -22,7 +22,7 @@ export const OVERVIEW_NAV_ITEMS: NavItem[] = [ { key: "options", label: "Options", icon: "window" }, { key: "insiders", label: "Insiders", icon: "pulse" }, { key: "filings", label: "Filings", icon: "folder" }, - { key: "news", label: "News", icon: "terminal", disabled: true } + { key: "news", label: "News", icon: "terminal" } ]; export function buildIdentityLine(overview: TickerOverview): string { diff --git a/frontend/types/api.ts b/frontend/types/api.ts index fac9bff..8143d56 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -252,3 +252,27 @@ export type RatiosResponse = { dividend_yield: RatioPoint; dividend_payout: RatioPoint; }; + +export type NewsItem = { + id: string; + title: string; + summary: string; + source: string; + url: string; + sentiment: "bullish" | "neutral" | "bearish"; + published_at: string; +}; + +export type NewsAggregate = { + buzz_articles_last_week: number; + bullish_pct: number; + neutral_pct: number; + bearish_pct: number; +}; + +export type NewsResponse = { + items: NewsItem[]; + aggregate: NewsAggregate | null; + provider: "finnhub" | "fmp" | null; + fetched_at: string; +}; -- cgit v1.3-2-g0d8e