import { useMemo, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTickerDetail } from '../hooks/useTickerDetail';
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 { useToast } from '../components/ui/Toast';
import { fetchData } from '../api/ingestion';
import { formatPrice } from '../lib/format';
import type { TradeSetup } from '../lib/types';
function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
{message}
{onRetry && (
)}
);
}
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;
}
function DataFreshnessBar({ items }: { items: DataStatusItem[] }) {
return (
{items.map((item) => (
{item.label}
{item.available && item.timestamp && (
{timeAgo(item.timestamp)}
)}
{!item.available && (
no data
)}
))}
);
}
export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>();
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
const queryClient = useQueryClient();
const { addToast } = useToast();
const ingestion = useMutation({
mutationFn: () => fetchData(symbol),
onSuccess: (result: any) => {
// Show per-source status breakdown
const sources = result?.sources;
if (sources) {
const parts: string[] = [];
for (const [name, info] of Object.entries(sources) as [string, any][]) {
const label = name.charAt(0).toUpperCase() + name.slice(1);
if (info.status === 'ok') {
parts.push(`${label} ✓`);
} else if (info.status === 'skipped') {
parts.push(`${label}: skipped (${info.message})`);
} else {
parts.push(`${label} ✗: ${info.message}`);
}
}
const hasError = Object.values(sources).some((s: any) => s.status === 'error');
const hasSkip = Object.values(sources).some((s: any) => s.status === 'skipped');
const toastType = hasError ? 'error' : hasSkip ? 'info' : 'success';
addToast(toastType, parts.join(' · '));
} else {
addToast('success', `Data fetched for ${symbol.toUpperCase()}`);
}
queryClient.invalidateQueries({ queryKey: ['ohlcv', symbol] });
queryClient.invalidateQueries({ queryKey: ['sentiment', symbol] });
queryClient.invalidateQueries({ queryKey: ['fundamentals', symbol] });
queryClient.invalidateQueries({ queryKey: ['sr-levels', symbol] });
queryClient.invalidateQueries({ queryKey: ['scores', symbol] });
},
onError: (err: Error) => {
addToast('error', err.message || 'Failed to fetch data');
},
});
const dataStatus: DataStatusItem[] = useMemo(() => [
{
label: 'OHLCV',
available: !!ohlcv.data && ohlcv.data.length > 0,
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at,
},
{
label: 'Sentiment',
available: !!sentiment.data && sentiment.data.count > 0,
timestamp: sentiment.data?.scores?.[0]?.timestamp,
},
{
label: 'Fundamentals',
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
timestamp: fundamentals.data?.fetched_at,
},
{
label: 'S/R Levels',
available: !!srLevels.data && srLevels.data.count > 0,
timestamp: srLevels.data?.levels?.[0]?.created_at,
},
{
label: 'Scores',
available: !!scores.data && scores.data.composite_score !== null,
timestamp: scores.data?.computed_at,
},
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
// 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]);
// Pick the latest trade setup for the current symbol
const tradeSetup: TradeSetup | undefined = useMemo(() => {
if (trades.error || !trades.data) return undefined;
const matching = trades.data.filter(
(t) => t.symbol.toUpperCase() === symbol.toUpperCase(),
);
if (matching.length === 0) return undefined;
return matching.reduce((latest, t) =>
new Date(t.detected_at) > new Date(latest.detected_at) ? t : latest,
);
}, [trades.data, trades.error, symbol]);
// 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()}
Ticker Detail
{/* Data freshness bar */}
{/* Chart Section */}
Price Chart
{ohlcv.isLoading && }
{ohlcv.isError && (
ohlcv.refetch()}
/>
)}
{ohlcv.data && (
{srLevels.isError && (
S/R levels unavailable — chart shown without overlays
)}
)}
{/* Trade Setup Summary Card */}
{tradeSetup && (
Trade Setup
Direction
{tradeSetup.direction.toUpperCase()}
Entry
{formatPrice(tradeSetup.entry_price)}
Stop
{formatPrice(tradeSetup.stop_loss)}
Target
{formatPrice(tradeSetup.target)}
R:R
{tradeSetup.rr_ratio.toFixed(2)}
)}
{/* Scores + Side Panels */}
Scores
{scores.isLoading && }
{scores.isError && (
scores.refetch()} />
)}
{scores.data && (
)}
Sentiment
{sentiment.isLoading && }
{sentiment.isError && (
sentiment.refetch()} />
)}
{sentiment.data && }
Fundamentals
{fundamentals.isLoading && }
{fundamentals.isError && (
fundamentals.refetch()} />
)}
{fundamentals.data && }
{/* Indicators */}
{/* S/R Levels Table — sorted by strength */}
{sortedLevels.length > 0 && (
Support & Resistance Levels
sorted by strength
| Type |
Price Level |
Strength |
Method |
{sortedLevels.map((level) => (
|
{level.type}
|
{formatPrice(level.price_level)} |
{level.strength} |
{level.detection_method} |
))}
)}
);
}