import { useState, useMemo, useRef, useEffect } from "react"; import { Breadcrumb } from "@/components/Breadcrumb"; import { useKeyboardNav, keyboardNavItemClass } from "@/hooks/useKeyboardNav"; import { trpc } from "@/lib/trpc"; import { useAuth } from "@/_core/hooks/useAuth"; import { Link } from "wouter"; import { Card, CardContent } from "@/components/ui/card"; import { NewsCard } from "@/components/TexturedCard"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { Newspaper, ArrowLeft, TrendingUp, Briefcase, Star, Calendar, Info, Globe, ExternalLink, BarChart3, Sparkles, ChevronRight } from "lucide-react"; import { LazyStreamdown as Streamdown } from "@/components/LazyStreamdown"; import { SEOHead, pageSEO } from "@/components/SEOHead"; import { PageTransition } from "@/components/PageTransition"; import { mergeAndDedup, getSourceLabel, getSourceColor, type UnifiedNewsItem } from "@/lib/newsDedup"; import { SentimentBadge } from "@/components/SentimentBadge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { QUERY_CACHE } from "@/lib/queryConfig"; import { EmptyState } from "@/components/EmptyState"; import { ElevenLabsListenButton } from "@/components/ElevenLabsListenButton"; import { ScrollReveal } from "@/components/ScrollReveal"; import { NewsSkeletonGrid } from "@/components/NewsCardSkeleton"; import { CitedMacroBriefing } from "@/components/features/CitedMacroBriefing"; export default function NewsFeed() { const { user } = useAuth(); const [activeTab, setActiveTab] = useState<"portfolio" | "watchlist" | "trending" | "market" | "briefings">("trending"); // Fetch macro briefings const { data: briefings, isLoading: isLoadingBriefings } = trpc.macroBriefing.list.useQuery( { limit: 10 }, { staleTime: 5 * 60 * 1000 } ); const [visibleCount, setVisibleCount] = useState(15); const loadMoreRef = useRef(null); // Fetch portfolio holdings const { data: holdings } = trpc.realPortfolio.getHoldings.useQuery(undefined, { ...QUERY_CACHE.USER_DATA, enabled: !!user, }); // Fetch watchlist const { data: watchlist } = trpc.watchlist.list.useQuery(undefined, { ...QUERY_CACHE.USER_DATA, enabled: !!user, }); // Get symbols from portfolio const portfolioSymbols = useMemo(() => { if (!holdings) return []; return holdings.map((h) => h.ticker); }, [holdings]); // Get symbols from watchlist const watchlistSymbols = useMemo(() => { if (!watchlist) return []; return watchlist.map((w) => w.ticker); }, [watchlist]); // Trending stocks for non-authenticated users const trendingSymbols = useMemo( () => ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "JPM", "V", "UNH"], [] ); // Polygon market news (for Market Wire tab) const { data: polygonMarketNews, isLoading: isLoadingPolygon } = trpc.polygonNews.marketNews.useQuery( { limit: 30 }, { enabled: activeTab === "market", staleTime: 5 * 60 * 1000 } ); // Benzinga news via Massive API (enriches Market Wire) const { data: benzingaNews } = trpc.massive.benzingaNews.useQuery( { limit: 20 }, { enabled: activeTab === "market", staleTime: 5 * 60 * 1000 } ); // Get active symbols based on tab const activeSymbols = useMemo(() => { switch (activeTab) { case "portfolio": return portfolioSymbols; case "watchlist": return watchlistSymbols; case "trending": default: return trendingSymbols; } }, [activeTab, portfolioSymbols, watchlistSymbols, trendingSymbols]); // ── Fetch from all 4 sources ────────────────────────────────── // Yahoo Finance news const { data: yahooNews, isLoading: isLoadingYahoo } = trpc.yahooFinance.newsFeed.useQuery( { symbols: activeSymbols, limit: 30 }, { enabled: activeSymbols.length > 0 && activeTab !== "market", staleTime: 5 * 60 * 1000 } ); // Polygon ticker news (for cross-source dedup) const { data: polygonTickerNews, isLoading: isLoadingPolygonTicker } = trpc.polygonNews.forTickers.useQuery( { tickers: activeSymbols, limit: 30 }, { enabled: activeSymbols.length > 0 && activeTab !== "market", staleTime: 5 * 60 * 1000 } ); // Newsdata.io finance news const { data: newsdataNews, isLoading: isLoadingNewsdata } = trpc.newsdata.finance.useQuery( { size: 25 }, { enabled: activeTab === "trending" || activeTab === "market", staleTime: 5 * 60 * 1000 } ); // APITUBE finance news const { data: apitubeNews, isLoading: isLoadingApitube } = trpc.apitube.finance.useQuery( { limit: 25 }, { enabled: activeTab === "trending" || activeTab === "market", staleTime: 5 * 60 * 1000 } ); // ── Merge and deduplicate from all sources ──────────────────── const mergedNews = useMemo(() => { if (activeTab === "market") { // Market Wire: merge Polygon + Benzinga + Newsdata + APITUBE const benzingaAsPolygon = (benzingaNews || []).map((a: any) => ({ id: a.benzinga_id || `bz-${(a.title || "").slice(0, 20)}`, title: a.title, author: a.author, publishedUtc: a.published, articleUrl: a.url, imageUrl: a.images?.[0]?.url || null, description: a.teaser || (a.body || "").slice(0, 200), keywords: a.tags || [], tickers: a.tickers || [], publisher: { name: "Benzinga", homepageUrl: "https://benzinga.com", logoUrl: "", faviconUrl: "" }, })); const combinedPolygon = [...(polygonMarketNews || []), ...benzingaAsPolygon]; return mergeAndDedup( undefined, combinedPolygon.length > 0 ? combinedPolygon : undefined, newsdataNews ?? undefined, apitubeNews ?? undefined, 50 ); } if (activeTab === "trending") { // Trending: all 4 sources return mergeAndDedup( yahooNews ?? undefined, polygonTickerNews ?? undefined, newsdataNews ?? undefined, apitubeNews ?? undefined, 50 ); } // Portfolio/Watchlist: Yahoo + Polygon for specific symbols return mergeAndDedup( yahooNews ?? undefined, polygonTickerNews ?? undefined, undefined, undefined, 40 ); }, [activeTab, yahooNews, polygonTickerNews, polygonMarketNews, newsdataNews, apitubeNews]); // P206-PERF3: Progressive rendering — show first 15 items, load more on scroll useEffect(() => { const el = loadMoreRef.current; if (!el) return; const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setVisibleCount((prev) => Math.min(prev + 15, 100)); } }, { rootMargin: '200px' } ); observer.observe(el); return () => observer.disconnect(); }, [mergedNews.length]); // Reset visible count when tab changes useEffect(() => { setVisibleCount(15); }, [activeTab]); // Progressive loading: only show spinner when NO data has arrived yet const hasAnyData = mergedNews.length > 0; const allSourcesLoading = isLoadingYahoo && isLoadingPolygonTicker && (activeTab === "trending" ? (isLoadingNewsdata && isLoadingApitube) : true) && (activeTab === "market" ? isLoadingPolygon : true); const isLoading = !hasAnyData && allSourcesLoading; const isPartiallyLoading = hasAnyData && (isLoadingYahoo || isLoadingPolygonTicker || isLoadingNewsdata || isLoadingApitube || isLoadingPolygon); // Keyboard navigation const newsRef = useRef(null); const { focusedIndex, isFocused } = useKeyboardNav({ itemCount: mergedNews.length, onSelect: (index) => { const item = mergedNews[index]; if (item?.articleUrl) { window.open(item.articleUrl, "_blank"); } else if (item?.symbol) { window.location.href = `/research/${item.symbol}`; } }, containerRef: newsRef as any, }); const formatDate = (dateStr: string) => { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays === 1) return "Yesterday"; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); }; // Count active sources const activeSources = useMemo(() => { const sources = new Set(mergedNews.map((n) => n.source)); return sources.size; }, [mergedNews]); SEOHead(pageSEO.news); return (
{/* Page context — site navigation is provided by LearnSiteHeader. */}

News Feed {activeSources > 1 && ( {activeSources} sources )}

setActiveTab(v as typeof activeTab)} className="min-w-0 space-y-6">
Trending Macro Briefings Portfolio {portfolioSymbols.length > 0 && `(${portfolioSymbols.length})`} Watchlist {watchlistSymbols.length > 0 && `(${watchlistSymbols.length})`} Market Wire
{/* Macro Briefings Tab Content */} {isLoadingBriefings ? (
{[...Array(3)].map((_, i) => (
))}
) : briefings && briefings.length > 0 ? (
Weekly educational summaries of economic indicator movements
{/* Cited Macro Briefing — on-demand Mycroft analysis */} {briefings.map((briefing) => ( ))}
) : (

No Briefings Yet

Weekly macro briefings are generated every Sunday. Check back soon for an educational summary of the week's economic indicator movements.

)} {/* Unified news content for all tabs */} {isLoading ? (

Loading sources: {[!isLoadingYahoo && 'Yahoo', !isLoadingPolygonTicker && 'Polygon', !isLoadingNewsdata && 'Newsdata', !isLoadingApitube && 'ApiTube'].filter(Boolean).length} / 4 resolved

) : mergedNews.length > 0 ? (
{mergedNews.slice(0, visibleCount).map((item, index) => (
{/* Article image */} {item.imageUrl && ( { (e.target as HTMLImageElement).style.display = "none"; }} /> )}
{/* Ticker badges */} {item.tickers && item.tickers.length > 0 ? item.tickers.slice(0, 4).map((ticker) => ( {ticker} )) : item.symbol ? ( {item.symbol} ) : null} {item.tickers && item.tickers.length > 4 && ( +{item.tickers.length - 4} )} {item.companyName && ( {item.companyName} )} {/* Source badge */} {item.source === "merged" ? "✓ Verified" : getSourceLabel(item.source)} {item.source === "merged" ? "Confirmed by multiple sources" : `Source: ${getSourceLabel(item.source)}`} {/* Sentiment badge */} {item.sentiment && ( )}
{/* Headline */} {item.articleUrl ? ( {item.headline} ) : (

{item.headline}

)} {/* Description */} {item.description && (

{item.description}

)} {/* Listen button */}
{/* Metadata row */}
{item.publisher?.name && ( {item.publisher.faviconUrl && ( { (e.target as HTMLImageElement).style.display = "none"; }} /> )} {item.publisher.name} )} {formatDate(item.date)} {item.author && item.author !== "Unknown" && ( by {item.author} )} {/* Category tags */} {item.categories && item.categories.length > 0 && ( {item.categories.slice(0, 2).join(" · ")} )}
))} {/* Progressive load sentinel */} {visibleCount < mergedNews.length && (
Showing {visibleCount} of {mergedNews.length} articles — scroll for more
)} {/* Partial loading indicator — more sources still resolving */} {isPartiallyLoading && (
)}
) : activeSymbols.length === 0 && activeTab !== "market" ? (

No Stocks to Track

{activeTab === "portfolio" ? "Add stocks to your portfolio to see relevant news." : activeTab === "watchlist" ? "Add stocks to your watchlist to see relevant news." : "No trending stocks available."}

{activeTab === "portfolio" && ( )} {activeTab === "watchlist" && ( )}
) : (

No Recent News

No significant developments found for your current selection. News sources are checked every few minutes.

Polygon Yahoo Newsdata APITUBE
)} {/* Educational Note */} {activeTab !== "briefings" && (

About Market News

Multi-Source Aggregation

This feed aggregates news from Yahoo Finance, Polygon.io, Newsdata.io, and APITUBE. Articles confirmed by multiple sources are marked with a{" "} ✓ Verified badge. Sentiment analysis (bullish/bearish/neutral) is provided where available.

Using News in Your Research

  • Context matters: Consider news alongside financial metrics and technical analysis.
  • Timing: Markets often react before news is public — be cautious of "buying the news."
  • Sentiment: Sentiment badges indicate market tone but should not be used as sole trading signals.
)}
); } // ─── Briefing Card Component ───────────────────────────────────────────────── function BriefingCard({ briefing }: { briefing: any }) { const [expanded, setExpanded] = useState(false); const formatBriefingDate = (date: Date | string | null) => { if (!date) return ""; return new Date(date).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", }); }; const indicators = briefing.indicators as Array<{ name: string; previousValue: number; currentValue: number; change: number; changePercent: number; significance: string; }> | null; return (
setExpanded(!expanded)} > {/* Header */}
Weekly Briefing {formatBriefingDate(briefing.publishedAt)}

{briefing.title}

{briefing.summary}

{/* Expanded Content */} {expanded && (
{/* Indicator Changes */} {indicators && indicators.length > 0 && (
{indicators.map((ind) => (
{ind.name}
{ind.currentValue.toFixed(1)} 0 ? "text-[oklch(0.72_0.19_142)]" : ind.change < 0 ? "text-[oklch(0.65_0.2_30)]" : "text-muted-foreground" }`} > {ind.change > 0 ? "+" : ""}{ind.changePercent.toFixed(1)}%
))}
)} {/* Full Content */}
{briefing.content}
)}
); }