import { useMemo, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { useTickerDetail } from '../hooks/useTickerDetail'; import { useFetchSymbolData } from '../hooks/useFetchSymbolData'; import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hooks/useWatchlist'; import type { FetchSelector } from '../api/ingestion'; import { CandlestickChart } from '../components/charts/CandlestickChart'; import { ScoreCard } from '../components/ui/ScoreCard'; import { SkeletonCard } from '../components/ui/Skeleton'; import { SentimentPanel } from '../components/ticker/SentimentPanel'; import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel'; import { IndicatorSelector } from '../components/ticker/IndicatorSelector'; import { RecommendationPanel } from '../components/ticker/RecommendationPanel'; import { Button } from '../components/ui/Button'; import { Callout } from '../components/ui/Callout'; import { Section } from '../components/ui/Section'; import { Tabs } from '../components/ui/Tabs'; import { formatPrice } from '../lib/format'; import type { TradeSetup } from '../lib/types'; const detailTabs = ['Analysis', 'Indicators', 'S/R Levels'] as const; type DetailTab = (typeof detailTabs)[number]; function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) { return ( {message} ); } function timeAgo(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diff / 60_000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); return `${days}d ago`; } interface DataStatusItem { label: string; available: boolean; timestamp?: string | null; selector: FetchSelector; // what a refresh of this row fetches paid?: boolean; // provider call that may cost money/quota } function RefreshIcon({ spinning }: { spinning: boolean }) { return ( ); } function DataFreshnessBar({ items, onRefresh, pendingLabel, busy, }: { items: DataStatusItem[]; onRefresh: (item: DataStatusItem) => void; pendingLabel: string | null; busy: boolean; }) { return (
{items.map((item) => (
{item.label} {item.available && item.timestamp ? ( {timeAgo(item.timestamp)} ) : !item.available ? ( no data ) : null} {item.paid && $}
))}
); } export default function TickerDetailPage() { const { symbol = '' } = useParams<{ symbol: string }>(); const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol); const ingestion = useFetchSymbolData(); const watchlist = useWatchlist(); const addToWatchlist = useAddToWatchlist(); const removeFromWatchlist = useRemoveFromWatchlist(); const onWatchlist = useMemo( () => (watchlist.data ?? []).some((e) => e.symbol.toUpperCase() === symbol.toUpperCase()), [watchlist.data, symbol], ); const watchlistBusy = addToWatchlist.isPending || removeFromWatchlist.isPending; const [activeTab, setActiveTab] = useState('Analysis'); const [refreshingLabel, setRefreshingLabel] = useState(null); const dataStatus: DataStatusItem[] = useMemo(() => [ { label: 'OHLCV', available: !!ohlcv.data && ohlcv.data.length > 0, timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at, selector: ['ohlcv'] as FetchSelector, paid: true, }, { label: 'Sentiment', available: !!sentiment.data && sentiment.data.count > 0, timestamp: sentiment.data?.scores?.[0]?.timestamp, selector: ['sentiment'] as FetchSelector, paid: true, }, { label: 'Fundamentals', available: !!fundamentals.data && fundamentals.data.fetched_at !== null, timestamp: fundamentals.data?.fetched_at, selector: ['fundamentals'] as FetchSelector, paid: true, }, { label: 'S/R Levels', available: !!srLevels.data && srLevels.data.count > 0, timestamp: srLevels.data?.levels?.[0]?.created_at, selector: 'recompute' as FetchSelector, }, { label: 'Scores', available: !!scores.data && scores.data.composite_score !== null, timestamp: scores.data?.computed_at, selector: 'recompute' as FetchSelector, }, ], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]); const handleRefresh = (item: DataStatusItem) => { setRefreshingLabel(item.label); ingestion.mutate( { symbol, sources: item.selector }, { onSettled: () => setRefreshingLabel(null) }, ); }; // Log trades API errors but don't disrupt the page useEffect(() => { if (trades.error) { console.error('Failed to fetch trade setups:', trades.error); } }, [trades.error]); const setupsForSymbol: TradeSetup[] = useMemo(() => { if (trades.error || !trades.data) return []; return trades.data.filter((t) => t.symbol.toUpperCase() === symbol.toUpperCase()); }, [trades.data, trades.error, symbol]); const longSetup = useMemo( () => setupsForSymbol?.find((s) => s.direction === 'long'), [setupsForSymbol], ); const shortSetup = useMemo( () => setupsForSymbol?.find((s) => s.direction === 'short'), [setupsForSymbol], ); // Current price = latest close, with day-over-day change const priceInfo = useMemo(() => { const bars = ohlcv.data; if (!bars || bars.length === 0) return null; const last = bars[bars.length - 1]; const prev = bars.length > 1 ? bars[bars.length - 2] : null; const change = prev && prev.close ? ((last.close - prev.close) / prev.close) * 100 : null; return { price: last.close, date: last.date, change }; }, [ohlcv.data]); // Which setup the chart overlays. 'auto' = the ticker's preferred direction. const [overlayChoice, setOverlayChoice] = useState<'auto' | 'long' | 'short' | 'none'>('auto'); const action = (longSetup ?? shortSetup)?.recommended_action ?? null; const overlaySetup: TradeSetup | undefined = useMemo(() => { if (overlayChoice === 'none') return undefined; if (overlayChoice === 'long') return longSetup; if (overlayChoice === 'short') return shortSetup; // auto: preferred direction's setup, else the highest-confidence available if (action?.startsWith('LONG') && longSetup) return longSetup; if (action?.startsWith('SHORT') && shortSetup) return shortSetup; const candidates = [longSetup, shortSetup].filter(Boolean) as TradeSetup[]; return candidates.sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0]; }, [overlayChoice, longSetup, shortSetup, action]); // Sort visible S/R levels by strength for the table (only levels within chart zones) const sortedLevels = useMemo(() => { if (!srLevels.data?.visible_levels) return []; return [...srLevels.data.visible_levels].sort((a, b) => b.strength - a.strength); }, [srLevels.data]); return (
{/* Header */}

{symbol.toUpperCase()}

{priceInfo && (
{formatPrice(priceInfo.price)} {priceInfo.change !== null && ( = 0 ? 'text-emerald-400' : 'text-red-400'}`}> {priceInfo.change >= 0 ? '+' : ''}{priceInfo.change.toFixed(2)}% )} last close · {timeAgo(priceInfo.date)}
)}
{/* Data freshness bar */} {/* Chart — always visible */}
{ohlcv.isLoading && } {ohlcv.isError && ( ohlcv.refetch()} /> )} {ohlcv.data && (
{(longSetup || shortSetup) && (
Overlay setup: {([ { key: 'auto', label: 'Auto', show: true }, { key: 'long', label: 'Long', show: !!longSetup }, { key: 'short', label: 'Short', show: !!shortSetup }, { key: 'none', label: 'None', show: true }, ] as const).filter((o) => o.show).map((o) => ( ))} {overlaySetup && ( showing {overlaySetup.direction.toUpperCase()} · entry {formatPrice(overlaySetup.entry_price)} → target {formatPrice(overlaySetup.target)} )}
)}

Only the nearest support & resistance are drawn. Full list in the S/R Levels tab. {srLevels.isError && ' S/R levels unavailable.'}

)}
{/* Detail tabs */} {activeTab === 'Analysis' && (
{scores.isLoading && } {scores.isError && ( scores.refetch()} /> )} {scores.data && ( )}
{sentiment.isLoading && } {sentiment.isError && ( sentiment.refetch()} /> )} {sentiment.data && }
{fundamentals.isLoading && } {fundamentals.isError && ( fundamentals.refetch()} /> )} {fundamentals.data && }
)} {activeTab === 'Indicators' && (
)} {activeTab === 'S/R Levels' && (
{sortedLevels.length === 0 ? ( No S/R levels detected for this ticker yet. ) : (
{sortedLevels.map((level) => ( ))}
Type Price Level Strength Method
{level.type} {formatPrice(level.price_level)} {level.strength} {level.detection_method}
)}
)}
); }