summaryrefslogtreecommitdiff
path: root/frontend/components/prism
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-06-22 15:11:51 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-06-22 15:11:51 -0700
commite66c7b9171c8c08da38120490bed15a9b877d222 (patch)
tree7a468ada2e53989c36555a42de12fe8069edbc49 /frontend/components/prism
parent911765d8d1094df1baed4c3a313988e18cc31755 (diff)
feat: add NewsPage component and enable News tab
Diffstat (limited to 'frontend/components/prism')
-rw-r--r--frontend/components/prism/NewsPage.tsx709
1 files changed, 709 insertions, 0 deletions
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<RangeFilter, string | null> {
+ 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<SentimentFilter, string> = {
+ all: "All",
+ bullish: "Bull",
+ neutral: "Neutral",
+ bearish: "Bear",
+};
+
+const RANGE_LABELS: Record<RangeFilter, string> = {
+ 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 (
+ <div
+ className={className}
+ style={{
+ ...style,
+ height,
+ borderRadius: "var(--r-3)",
+ background: "var(--ink-2, #181d26)",
+ animation: "psm-pulse 1.4s ease-in-out infinite",
+ }}
+ />
+ );
+}
+
+function NewsSkeleton() {
+ return (
+ <div data-testid="news-loading">
+ <SkeletonBlock height={120} style={{ marginBottom: "var(--sp-4)" }} />
+ <SkeletonBlock height={48} style={{ marginBottom: "var(--sp-4)" }} />
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--sp-3)" }}>
+ {[0, 1, 2].map((i) => (
+ <SkeletonBlock key={i} height={110} />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function LedeCard({
+ ticker,
+ itemCount,
+ provider,
+ aggregate,
+}: {
+ ticker: string;
+ itemCount: number;
+ provider: NewsResponse["provider"];
+ aggregate: NewsAggregate | null;
+}) {
+ const providerLabel = provider ?? "aggregated";
+
+ return (
+ <div
+ data-testid="news-lede"
+ className="psm-card"
+ style={{
+ display: "grid",
+ gridTemplateColumns: aggregate ? "1fr minmax(220px, auto)" : "1fr",
+ gap: "var(--sp-5)",
+ alignItems: "stretch",
+ marginBottom: "var(--sp-4)",
+ }}
+ >
+ <div style={{ display: "flex", flexDirection: "column", gap: "var(--sp-2)", justifyContent: "center" }}>
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-12)",
+ fontWeight: "var(--w-semibold)",
+ textTransform: "uppercase",
+ letterSpacing: "var(--tr-wider)",
+ color: "var(--fg-3)",
+ }}
+ >
+ Market Tape
+ </div>
+ <div
+ style={{
+ fontFamily: "var(--font-display)",
+ fontSize: "var(--fs-30)",
+ fontWeight: "var(--w-medium)",
+ letterSpacing: "var(--tr-tight)",
+ lineHeight: "var(--lh-snug)",
+ color: "var(--fg-1)",
+ }}
+ >
+ Headlines for {ticker}
+ </div>
+ <div
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-13)",
+ fontVariantNumeric: "tabular-nums",
+ color: "var(--fg-3)",
+ }}
+ >
+ 30 days · {itemCount} {itemCount === 1 ? "article" : "articles"} · {providerLabel}
+ </div>
+ </div>
+
+ {aggregate && (
+ <div
+ data-testid="news-lede-aggregate"
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ gap: "var(--sp-3)",
+ justifyContent: "center",
+ alignItems: "flex-end",
+ }}
+ >
+ <div
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-13)",
+ fontVariantNumeric: "tabular-nums",
+ color: "var(--fg-2)",
+ }}
+ >
+ Last 7d buzz: {aggregate.buzz_articles_last_week}
+ </div>
+ <div style={{ display: "flex", gap: "var(--sp-2)" }}>
+ <SentimentChip sentiment="bullish" label={`${Math.round(aggregate.bullish_pct * 100)}% Bull`} />
+ <SentimentChip sentiment="neutral" label={`${Math.round(aggregate.neutral_pct * 100)}% Neutral`} />
+ <SentimentChip sentiment="bearish" label={`${Math.round(aggregate.bearish_pct * 100)}% Bear`} />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function SentimentChip({ sentiment, label }: { sentiment: SentimentFilter; label: string }) {
+ const colors: Record<SentimentFilter, { bg: string; color: string; border: string }> = {
+ 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 (
+ <span
+ style={{
+ display: "inline-flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "2px 10px",
+ borderRadius: "999px",
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-12)",
+ fontVariantNumeric: "tabular-nums",
+ fontWeight: "var(--w-medium)",
+ background: colors[sentiment].bg,
+ color: colors[sentiment].color,
+ border: colors[sentiment].border,
+ whiteSpace: "nowrap",
+ }}
+ >
+ {label}
+ </span>
+ );
+}
+
+function FilterChip({
+ label,
+ active,
+ onClick,
+ pressed,
+}: {
+ label: string;
+ active: boolean;
+ onClick: () => void;
+ pressed?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ aria-pressed={pressed ?? active}
+ style={{
+ padding: "5px 14px",
+ borderRadius: "999px",
+ border: active ? "1px solid var(--brass, #C2AA7A)" : "1px solid var(--line-2, #2E3645)",
+ background: active ? "var(--brass, #C2AA7A)" : "var(--ink-2, #181d26)",
+ color: active ? "var(--brass-ink, #17120A)" : "var(--fg-3, #8E8676)",
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-12)",
+ fontWeight: "var(--w-medium)",
+ letterSpacing: "var(--tr-wide)",
+ cursor: "pointer",
+ transition: "background 150ms ease, color 150ms ease, border-color 150ms ease",
+ whiteSpace: "nowrap",
+ }}
+ >
+ {label}
+ </button>
+ );
+}
+
+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<RangeFilter, string | null>;
+}) {
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+
+ const sourceCounts = useMemo(() => {
+ const counts = new Map<string, number>();
+ 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 (
+ <div
+ className="psm-card"
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ gap: "var(--sp-3)",
+ marginBottom: "var(--sp-4)",
+ padding: "var(--sp-3) var(--sp-4)",
+ }}
+ >
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--sp-4)", alignItems: "center" }}>
+ <div data-testid="news-filter-range" role="group" aria-label="Date range" style={{ display: "flex", gap: "var(--sp-2)", flexWrap: "wrap", alignItems: "center" }}>
+ <span
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-11)",
+ textTransform: "uppercase",
+ letterSpacing: "var(--tr-wider)",
+ color: "var(--fg-4, #5E5849)",
+ }}
+ >
+ Range
+ </span>
+ {ranges.map((r) => (
+ <FilterChip key={r} label={RANGE_LABELS[r]} active={range === r} onClick={() => setRange(r)} />
+ ))}
+ </div>
+
+ <div data-testid="news-filter-source" role="group" aria-label="Source" style={{ display: "flex", gap: "var(--sp-2)", flexWrap: "wrap", alignItems: "center" }}>
+ <span
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-11)",
+ textTransform: "uppercase",
+ letterSpacing: "var(--tr-wider)",
+ color: "var(--fg-4, #5E5849)",
+ }}
+ >
+ Source
+ </span>
+ <FilterChip label="All" active={source === "all" && !isTailSourceSelected} onClick={() => setSource("all")} />
+ {topSources.map(([src, count]) => (
+ <FilterChip
+ key={src}
+ label={`${src} (${count})`}
+ active={source === src}
+ onClick={() => setSource(src)}
+ />
+ ))}
+ {tailSources.length > 0 && (
+ <div style={{ position: "relative" }}>
+ <button
+ type="button"
+ data-testid="news-filter-source-more"
+ aria-expanded={dropdownOpen}
+ aria-controls="news-source-dropdown"
+ onClick={() => setDropdownOpen((open) => !open)}
+ style={{
+ padding: "5px 14px",
+ borderRadius: "999px",
+ border: isTailSourceSelected
+ ? "1px solid var(--brass, #C2AA7A)"
+ : "1px solid var(--line-2, #2E3645)",
+ background: isTailSourceSelected ? "var(--brass, #C2AA7A)" : "var(--ink-2, #181d26)",
+ color: isTailSourceSelected ? "var(--brass-ink, #17120A)" : "var(--fg-3, #8E8676)",
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-12)",
+ fontWeight: "var(--w-medium)",
+ letterSpacing: "var(--tr-wide)",
+ cursor: "pointer",
+ transition: "background 150ms ease, color 150ms ease, border-color 150ms ease",
+ whiteSpace: "nowrap",
+ }}
+ >
+ {isTailSourceSelected ? `${source} ▾` : `More (${tailSources.length}) ▾`}
+ </button>
+ {dropdownOpen && (
+ <div
+ id="news-source-dropdown"
+ data-testid="news-source-dropdown"
+ style={{
+ position: "absolute",
+ top: "calc(100% + var(--sp-1))",
+ left: 0,
+ zIndex: 10,
+ minWidth: 180,
+ background: "var(--ink-2, #181d26)",
+ border: "1px solid var(--line-2, #2E3645)",
+ borderRadius: "var(--r-3)",
+ padding: "var(--sp-2)",
+ display: "flex",
+ flexDirection: "column",
+ gap: "var(--sp-1)",
+ boxShadow: "var(--shadow-2)",
+ }}
+ >
+ {tailSources.map(([src, count]) => (
+ <button
+ key={src}
+ type="button"
+ onClick={() => {
+ setSource(src);
+ setDropdownOpen(false);
+ }}
+ style={{
+ textAlign: "left",
+ padding: "var(--sp-2) var(--sp-3)",
+ borderRadius: "var(--r-2)",
+ border: "none",
+ background: "transparent",
+ color: "var(--fg-2, #C7C0AE)",
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-13)",
+ cursor: "pointer",
+ transition: "background 150ms ease, color 150ms ease",
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.background = "var(--ink-3, #222934)";
+ e.currentTarget.style.color = "var(--fg-1, #F2ECDC)";
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = "transparent";
+ e.currentTarget.style.color = "var(--fg-2, #C7C0AE)";
+ }}
+ >
+ {src} ({count})
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ <div data-testid="news-filter-sentiment" role="group" aria-label="Sentiment" style={{ display: "flex", gap: "var(--sp-2)", flexWrap: "wrap", alignItems: "center" }}>
+ <span
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-11)",
+ textTransform: "uppercase",
+ letterSpacing: "var(--tr-wider)",
+ color: "var(--fg-4, #5E5849)",
+ }}
+ >
+ Sentiment
+ </span>
+ {sentiments.map((s) => (
+ <FilterChip
+ key={s}
+ label={SENTIMENT_LABELS[s]}
+ active={sentiment === s}
+ onClick={() => setSentiment(s)}
+ />
+ ))}
+ </div>
+ </div>
+
+ <div
+ data-testid="news-readout"
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-13)",
+ fontVariantNumeric: "tabular-nums",
+ color: "var(--fg-3, #8E8676)",
+ textAlign: "right",
+ borderTop: "1px solid var(--line-1, #232934)",
+ paddingTop: "var(--sp-3)",
+ }}
+ >
+ {filtered.length} {filtered.length === 1 ? "article" : "articles"} · {sentimentCounts.bullish} bull · {sentimentCounts.neutral} neutral · {sentimentCounts.bearish} bear · {distinctSources} {distinctSources === 1 ? "source" : "sources"}
+ </div>
+ </div>
+ );
+}
+
+function NewsCard({ item }: { item: NewsItem }) {
+ const timestamp = formatNewsTimestamp(item.published_at);
+
+ return (
+ <a
+ data-testid="news-card"
+ href={item.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="psm-card"
+ title={item.published_at}
+ style={{
+ display: "flex",
+ flexDirection: "column",
+ gap: "var(--sp-2)",
+ textDecoration: "none",
+ color: "inherit",
+ transition: "border-color 150ms ease",
+ }}
+ >
+ <div
+ data-testid="news-card-title"
+ style={{
+ fontFamily: "var(--font-display)",
+ fontSize: "var(--fs-18)",
+ fontWeight: "var(--w-medium)",
+ lineHeight: "var(--lh-snug)",
+ color: "var(--fg-1, #F2ECDC)",
+ display: "-webkit-box",
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: "vertical",
+ overflow: "hidden",
+ }}
+ >
+ {item.title}
+ </div>
+ {item.summary && (
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-14)",
+ lineHeight: "var(--lh-normal)",
+ color: "var(--fg-3, #8E8676)",
+ display: "-webkit-box",
+ WebkitLineClamp: 2,
+ WebkitBoxOrient: "vertical",
+ overflow: "hidden",
+ }}
+ >
+ {item.summary}
+ </div>
+ )}
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: "var(--sp-2)",
+ marginTop: "var(--sp-1)",
+ }}
+ >
+ <span
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-12)",
+ fontVariantNumeric: "tabular-nums",
+ color: "var(--fg-3, #8E8676)",
+ }}
+ >
+ {timestamp.display}
+ </span>
+ <span style={{ color: "var(--fg-4, #5E5849)" }}>·</span>
+ <span
+ data-testid="news-card-source"
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-12)",
+ color: "var(--fg-3, #8E8676)",
+ }}
+ >
+ {item.source}
+ </span>
+ <div style={{ marginLeft: "auto" }}>
+ <span data-testid="news-card-sentiment">
+ <SentimentChip sentiment={item.sentiment} label={SENTIMENT_LABELS[item.sentiment]} />
+ </span>
+ </div>
+ </div>
+ </a>
+ );
+}
+
+export function NewsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) {
+ const [data, setData] = useState<NewsResponse | null>(null);
+ const [state, setState] = useState<NewsState>("loading");
+ const [range, setRange] = useState<RangeFilter>("all");
+ const [source, setSource] = useState<string>("all");
+ const [sentiment, setSentiment] = useState<SentimentFilter>("all");
+ const [rangeCutoff, setRangeCutoff] = useState<Record<RangeFilter, string | null>>(() => 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 (
+ <div data-testid="news-page">
+ <TickerHeader overview={overview} onToggleWatchlist={onToggleWatchlist} isSaved={isSaved} />
+ <KPIStrip items={kpis} />
+
+ <div style={{ padding: "0 var(--sp-1)" }}>
+ {state === "loading" && <NewsSkeleton />}
+
+ {state === "error" && (
+ <div data-testid="news-error" className="psm-card" style={{ textAlign: "center", padding: "var(--sp-8)" }}>
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-14)",
+ color: "var(--fg-3)",
+ }}
+ >
+ Failed to load news
+ </div>
+ </div>
+ )}
+
+ {state === "empty" && data && (
+ <div data-testid="news-empty" className="psm-card" style={{ textAlign: "center", padding: "var(--sp-8)" }}>
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-14)",
+ color: "var(--fg-3)",
+ }}
+ >
+ No recent news for {ticker}
+ </div>
+ </div>
+ )}
+
+ {state === "ready" && data && (
+ <>
+ <LedeCard
+ ticker={ticker}
+ itemCount={data.items.length}
+ provider={data.provider}
+ aggregate={data.aggregate}
+ />
+ <FilterBar
+ items={data.items}
+ range={range}
+ setRange={setRange}
+ source={source}
+ setSource={setSource}
+ sentiment={sentiment}
+ setSentiment={setSentiment}
+ rangeCutoff={rangeCutoff}
+ />
+ {filteredItems.length === 0 ? (
+ <div
+ data-testid="news-filter-empty"
+ className="psm-card"
+ style={{ textAlign: "center", padding: "var(--sp-8)" }}
+ >
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-14)",
+ color: "var(--fg-3)",
+ marginBottom: "var(--sp-2)",
+ }}
+ >
+ No matches
+ </div>
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-13)",
+ color: "var(--fg-4)",
+ }}
+ >
+ Try widening the date range or clearing filters.
+ </div>
+ </div>
+ ) : (
+ <div data-testid="news-feed" style={{ display: "flex", flexDirection: "column", gap: "var(--sp-3)" }}>
+ {filteredItems.map((item) => (
+ <NewsCard key={item.id} item={item} />
+ ))}
+ </div>
+ )}
+ </>
+ )}
+ </div>
+
+ <style>{`
+ @keyframes psm-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.55; }
+ }
+ [data-testid="news-card"]:hover,
+ [data-testid="news-card"]:focus {
+ border-color: var(--brass, #C2AA7A);
+ }
+ `}</style>
+ </div>
+ );
+}