diff options
Diffstat (limited to 'frontend')
| -rw-r--r-- | frontend/app/page.tsx | 8 | ||||
| -rw-r--r-- | frontend/components/prism/NewsPage.tsx | 709 | ||||
| -rw-r--r-- | frontend/lib/api.ts | 7 | ||||
| -rw-r--r-- | frontend/lib/format.ts | 35 | ||||
| -rw-r--r-- | frontend/lib/overview.ts | 2 | ||||
| -rw-r--r-- | frontend/types/api.ts | 24 |
6 files changed, 783 insertions, 2 deletions
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" ? ( + <NewsPage + ticker={selectedTicker} + overview={overview} + isSaved={isSaved} + onToggleWatchlist={addOrRemoveCurrentTicker} + /> ) : ( <> <TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} /> 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> + ); +} 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<NewsResponse>( + `/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; +}; |
