809 lines
38 KiB
TypeScript
809 lines
38 KiB
TypeScript
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { Calendar, RefreshCw, Filter, ChevronRight, TrendingUp, TrendingDown, Minus, History, Search, X, Play, Loader2, Square, AlertCircle, Terminal } from 'lucide-react';
|
|
import TopPicks, { StocksToAvoid } from '../components/TopPicks';
|
|
import { DecisionBadge, HoldDaysBadge, RankBadge } from '../components/StockCard';
|
|
import TerminalModal from '../components/TerminalModal';
|
|
import HowItWorks from '../components/HowItWorks';
|
|
import BackgroundSparkline from '../components/BackgroundSparkline';
|
|
import { getLatestRecommendation, getBacktestResult as getStaticBacktestResult } from '../data/recommendations';
|
|
import { api } from '../services/api';
|
|
import { useSettings } from '../contexts/SettingsContext';
|
|
import { useNotification } from '../contexts/NotificationContext';
|
|
import { NIFTY_50_STOCKS } from '../types';
|
|
import type { Decision, StockAnalysis, DailyRecommendation, NiftyStock } from '../types';
|
|
|
|
type FilterType = 'ALL' | Decision;
|
|
|
|
export default function Dashboard() {
|
|
// State for real API data
|
|
const [recommendation, setRecommendation] = useState<DailyRecommendation | null>(null);
|
|
const [isLoadingData, setIsLoadingData] = useState(true);
|
|
const [isUsingMockData, setIsUsingMockData] = useState(false);
|
|
|
|
// Fetch real recommendation from API
|
|
const fetchRecommendation = useCallback(async () => {
|
|
setIsLoadingData(true);
|
|
try {
|
|
const data = await api.getLatestRecommendation();
|
|
if (data && data.analysis && Object.keys(data.analysis).length > 0) {
|
|
setRecommendation(data);
|
|
setIsUsingMockData(false);
|
|
} else {
|
|
// API returned empty data, fall back to mock
|
|
const mockData = getLatestRecommendation();
|
|
setRecommendation(mockData || null);
|
|
setIsUsingMockData(true);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch recommendation from API:', error);
|
|
// Fall back to mock data
|
|
const mockData = getLatestRecommendation();
|
|
setRecommendation(mockData || null);
|
|
setIsUsingMockData(true);
|
|
} finally {
|
|
setIsLoadingData(false);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch on mount
|
|
useEffect(() => {
|
|
fetchRecommendation();
|
|
}, [fetchRecommendation]);
|
|
const [filter, setFilter] = useState<FilterType>('ALL');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [sortBy, setSortBy] = useState<'rank' | 'symbol'>('rank');
|
|
const { settings } = useSettings();
|
|
const { addNotification } = useNotification();
|
|
|
|
// Terminal modal state
|
|
const [isTerminalOpen, setIsTerminalOpen] = useState(false);
|
|
|
|
// Track completed count to trigger incremental re-fetch
|
|
const prevCompletedRef = useRef(0);
|
|
|
|
// Bulk analysis state — initialize from localStorage for instant display on refresh
|
|
const [isAnalyzing, setIsAnalyzing] = useState(() => {
|
|
try {
|
|
return localStorage.getItem('bulkAnalysisRunning') === 'true';
|
|
} catch { return false; }
|
|
});
|
|
const [isCancelling, setIsCancelling] = useState(false);
|
|
const [analysisProgress, setAnalysisProgress] = useState<{
|
|
status: string;
|
|
total: number;
|
|
completed: number;
|
|
failed: number;
|
|
skipped?: number;
|
|
current_symbol: string | null;
|
|
current_symbols: string[];
|
|
results: Record<string, string>;
|
|
parallel_workers?: number;
|
|
stock_progress?: Record<string, { done: number; total: number; current: string | null }>;
|
|
} | null>(() => {
|
|
try {
|
|
const saved = localStorage.getItem('bulkAnalysisProgress');
|
|
return saved ? JSON.parse(saved) : null;
|
|
} catch { return null; }
|
|
});
|
|
|
|
// Persist analysis state to localStorage
|
|
const updateAnalysisState = useCallback((analyzing: boolean, progress: typeof analysisProgress) => {
|
|
setIsAnalyzing(analyzing);
|
|
setAnalysisProgress(progress);
|
|
try {
|
|
if (analyzing) {
|
|
localStorage.setItem('bulkAnalysisRunning', 'true');
|
|
if (progress) localStorage.setItem('bulkAnalysisProgress', JSON.stringify(progress));
|
|
} else {
|
|
localStorage.removeItem('bulkAnalysisRunning');
|
|
localStorage.removeItem('bulkAnalysisProgress');
|
|
}
|
|
} catch { /* localStorage unavailable */ }
|
|
}, []);
|
|
|
|
// Validate analysis state against backend on mount
|
|
useEffect(() => {
|
|
const checkAnalysisStatus = async () => {
|
|
try {
|
|
const status = await api.getBulkAnalysisStatus();
|
|
if (status.status === 'running') {
|
|
updateAnalysisState(true, status);
|
|
} else if (isAnalyzing) {
|
|
// localStorage said running but backend says otherwise (server restarted)
|
|
updateAnalysisState(false, null);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to check analysis status:', e);
|
|
}
|
|
};
|
|
checkAnalysisStatus();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
// Poll for analysis progress + incremental re-fetch
|
|
useEffect(() => {
|
|
if (!isAnalyzing) return;
|
|
|
|
const pollInterval = setInterval(async () => {
|
|
try {
|
|
const status = await api.getBulkAnalysisStatus();
|
|
// Persist progress to localStorage on every poll
|
|
updateAnalysisState(true, status);
|
|
|
|
// Incremental re-fetch: when completed count increases, refresh recommendation data
|
|
const newCompleted = status.completed + (status.skipped || 0);
|
|
if (newCompleted > prevCompletedRef.current) {
|
|
prevCompletedRef.current = newCompleted;
|
|
try {
|
|
const data = await api.getLatestRecommendation();
|
|
if (data && data.analysis && Object.keys(data.analysis).length > 0) {
|
|
setRecommendation(data);
|
|
setIsUsingMockData(false);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Re-fetch during analysis failed:', e);
|
|
}
|
|
}
|
|
|
|
if (status.status === 'completed') {
|
|
updateAnalysisState(false, null);
|
|
prevCompletedRef.current = 0;
|
|
clearInterval(pollInterval);
|
|
// Final re-fetch for complete dataset
|
|
fetchRecommendation();
|
|
addNotification({
|
|
type: 'success',
|
|
title: 'Analysis Complete',
|
|
message: `Successfully analyzed ${status.completed} stocks.${status.skipped ? ` ${status.skipped} already analyzed (skipped).` : ''} ${status.failed > 0 ? `${status.failed} failed.` : ''}`,
|
|
duration: 8000,
|
|
});
|
|
} else if (status.status === 'cancelled') {
|
|
updateAnalysisState(false, null);
|
|
prevCompletedRef.current = 0;
|
|
setIsCancelling(false);
|
|
clearInterval(pollInterval);
|
|
fetchRecommendation();
|
|
addNotification({
|
|
type: 'warning',
|
|
title: 'Analysis Cancelled',
|
|
message: `Cancelled after analyzing ${status.completed} stocks.`,
|
|
duration: 5000,
|
|
});
|
|
} else if (status.status === 'idle') {
|
|
updateAnalysisState(false, null);
|
|
prevCompletedRef.current = 0;
|
|
clearInterval(pollInterval);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to poll analysis status:', e);
|
|
}
|
|
}, 3000);
|
|
|
|
return () => clearInterval(pollInterval);
|
|
}, [isAnalyzing, addNotification, updateAnalysisState, fetchRecommendation]);
|
|
|
|
const handleAnalyzeAll = async () => {
|
|
if (isAnalyzing) return;
|
|
|
|
const initialProgress = {
|
|
status: 'starting',
|
|
total: 50,
|
|
completed: 0,
|
|
failed: 0,
|
|
current_symbol: null as string | null
|
|
};
|
|
updateAnalysisState(true, initialProgress);
|
|
|
|
try {
|
|
// Pass settings from context to the API
|
|
const result = await api.runBulkAnalysis(undefined, {
|
|
deep_think_model: settings.deepThinkModel,
|
|
quick_think_model: settings.quickThinkModel,
|
|
provider: settings.provider,
|
|
api_key: settings.provider === 'anthropic_api' ? settings.anthropicApiKey : undefined,
|
|
max_debate_rounds: settings.maxDebateRounds,
|
|
parallel_workers: settings.parallelWorkers
|
|
});
|
|
|
|
// If all stocks already analyzed, exit analyzing mode
|
|
if (result.status === 'completed' || result.total_stocks === 0) {
|
|
updateAnalysisState(false, null);
|
|
addNotification({
|
|
type: 'info',
|
|
title: 'Already Analyzed',
|
|
message: result.skipped
|
|
? `All ${result.skipped} stocks already analyzed for today.`
|
|
: 'All stocks already analyzed for today.',
|
|
duration: 5000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
addNotification({
|
|
type: 'info',
|
|
title: 'Analysis Started',
|
|
message: `Running AI analysis for ${result.total_stocks} stocks${result.skipped ? ` (${result.skipped} already done)` : ''}...`,
|
|
duration: 3000,
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to start bulk analysis:', e);
|
|
updateAnalysisState(false, null);
|
|
addNotification({
|
|
type: 'error',
|
|
title: 'Analysis Failed',
|
|
message: 'Failed to start bulk analysis. Please try again.',
|
|
duration: 5000,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCancelAnalysis = async () => {
|
|
if (!isAnalyzing || isCancelling) return;
|
|
|
|
setIsCancelling(true);
|
|
try {
|
|
await api.cancelBulkAnalysis();
|
|
addNotification({
|
|
type: 'info',
|
|
title: 'Cancelling...',
|
|
message: 'Stopping analysis after current stocks complete.',
|
|
duration: 3000,
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to cancel analysis:', e);
|
|
setIsCancelling(false);
|
|
addNotification({
|
|
type: 'error',
|
|
title: 'Cancel Failed',
|
|
message: 'Failed to cancel analysis.',
|
|
duration: 3000,
|
|
});
|
|
}
|
|
};
|
|
|
|
// Live state for each stock in the grid
|
|
type StockLiveState = 'completed' | 'analyzing' | 'pending' | 'failed';
|
|
interface StockGridItem {
|
|
symbol: string;
|
|
company_name: string;
|
|
liveState: StockLiveState;
|
|
analysis: StockAnalysis | null;
|
|
}
|
|
|
|
// Build unified stock grid: during analysis show all 50, otherwise only analyzed
|
|
const stockGridItems = useMemo((): StockGridItem[] => {
|
|
if (!isAnalyzing || !analysisProgress) {
|
|
// Normal mode: only show analyzed stocks
|
|
return (recommendation ? Object.values(recommendation.analysis) : []).map(s => ({
|
|
symbol: s.symbol,
|
|
company_name: s.company_name,
|
|
liveState: 'completed' as StockLiveState,
|
|
analysis: s,
|
|
}));
|
|
}
|
|
|
|
// Analysis mode: show all 50 stocks with live states
|
|
const analysisResults = analysisProgress.results || {};
|
|
const currentSymbols = new Set(analysisProgress.current_symbols || []);
|
|
const analysisMap = recommendation?.analysis || {};
|
|
|
|
return NIFTY_50_STOCKS.map((niftyStock: NiftyStock): StockGridItem => {
|
|
const { symbol } = niftyStock;
|
|
const resultStatus = analysisResults[symbol];
|
|
const existingAnalysis = analysisMap[symbol] || null;
|
|
|
|
let liveState: StockLiveState;
|
|
if (existingAnalysis) {
|
|
liveState = 'completed';
|
|
} else if (resultStatus === 'completed') {
|
|
liveState = 'completed'; // just completed, data not re-fetched yet
|
|
} else if (resultStatus && resultStatus.startsWith('error')) {
|
|
liveState = 'failed';
|
|
} else if (currentSymbols.has(symbol)) {
|
|
liveState = 'analyzing';
|
|
} else {
|
|
liveState = 'pending';
|
|
}
|
|
|
|
return {
|
|
symbol,
|
|
company_name: niftyStock.company_name,
|
|
liveState,
|
|
analysis: existingAnalysis,
|
|
};
|
|
});
|
|
}, [isAnalyzing, analysisProgress, recommendation]);
|
|
|
|
// Filter grid items based on filter and search query, then sort
|
|
const filteredItems = useMemo(() => {
|
|
let result = stockGridItems;
|
|
if (filter !== 'ALL') {
|
|
// During analysis, show matching completed + all non-completed (so progress stays visible)
|
|
result = result.filter(item =>
|
|
item.liveState !== 'completed' || item.analysis?.decision === filter
|
|
);
|
|
}
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(item =>
|
|
item.symbol.toLowerCase().includes(query) ||
|
|
item.company_name.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
if (sortBy === 'rank') {
|
|
result = [...result].sort((a, b) => {
|
|
const aRank = a.analysis?.rank ?? Infinity;
|
|
const bRank = b.analysis?.rank ?? Infinity;
|
|
if (aRank !== bRank) return aRank - bRank;
|
|
return a.symbol.localeCompare(b.symbol);
|
|
});
|
|
}
|
|
return result;
|
|
}, [stockGridItems, filter, searchQuery, sortBy]);
|
|
|
|
// Show loading state — but also include analysis progress banner if running
|
|
if (isLoadingData || !recommendation) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{isAnalyzing && analysisProgress && (
|
|
<div className="p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-800">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin text-blue-600 dark:text-blue-400" />
|
|
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
|
{isCancelling ? 'Cancelling...' : `Analyzing ${analysisProgress.current_symbols?.length > 0 ? analysisProgress.current_symbols.join(', ') : analysisProgress.current_symbol || 'stocks'}...`}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-blue-600 dark:text-blue-400">
|
|
{analysisProgress.completed + analysisProgress.failed} / {analysisProgress.total} stocks
|
|
{analysisProgress.skipped ? ` (${analysisProgress.skipped} skipped)` : ''}
|
|
</span>
|
|
<button
|
|
onClick={handleCancelAnalysis}
|
|
disabled={isCancelling}
|
|
className={`
|
|
flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all
|
|
${isCancelling
|
|
? 'bg-gray-200 text-gray-500 cursor-not-allowed dark:bg-gray-700 dark:text-gray-400'
|
|
: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
|
}
|
|
`}
|
|
title="Cancel analysis"
|
|
>
|
|
<Square className="w-3 h-3" />
|
|
{isCancelling ? 'Cancelling...' : 'Cancel'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
|
|
<div
|
|
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${analysisProgress.total > 0 ? ((analysisProgress.completed + analysisProgress.failed) / analysisProgress.total) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
{analysisProgress.failed > 0 && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
|
{analysisProgress.failed} failed
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="min-h-[40vh] flex items-center justify-center">
|
|
<div className="text-center">
|
|
<RefreshCw className="w-10 h-10 text-gray-300 mx-auto mb-3 animate-spin" />
|
|
<h2 className="text-lg font-semibold text-gray-700 dark:text-gray-300">Loading recommendations...</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Fetching data from API...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { buy, sell, hold, total: analyzedTotal } = recommendation.summary;
|
|
const total = isAnalyzing ? 50 : analyzedTotal;
|
|
const buyPct = total > 0 ? ((buy / total) * 100).toFixed(0) : '0';
|
|
const holdPct = total > 0 ? ((hold / total) * 100).toFixed(0) : '0';
|
|
const sellPct = total > 0 ? ((sell / total) * 100).toFixed(0) : '0';
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Compact Header with Stats */}
|
|
<section className="card p-5">
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-display font-bold text-gray-900 dark:text-gray-100 tracking-tight">
|
|
Nifty 50 <span className="gradient-text">AI Recommendations</span>
|
|
</h1>
|
|
<div className="flex items-center gap-2 mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
|
<Calendar className="w-3.5 h-3.5" />
|
|
<span>{new Date(recommendation.date).toLocaleDateString('en-IN', {
|
|
weekday: 'short',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Analyze All Button + Inline Stats */}
|
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3" role="group" aria-label="Summary statistics">
|
|
{/* Analyze All Button */}
|
|
<button
|
|
onClick={handleAnalyzeAll}
|
|
disabled={isAnalyzing}
|
|
className={`
|
|
flex items-center gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg text-sm font-semibold transition-all
|
|
${isAnalyzing
|
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 cursor-not-allowed'
|
|
: 'bg-nifty-600 text-white hover:bg-nifty-700 shadow-sm hover:shadow-md'
|
|
}
|
|
`}
|
|
title={isAnalyzing ? 'Analysis in progress...' : 'Run AI analysis for all 50 stocks'}
|
|
>
|
|
{isAnalyzing ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Play className="w-4 h-4" />
|
|
)}
|
|
<span className="hidden sm:inline">{isAnalyzing ? 'Analyzing...' : 'Analyze All'}</span>
|
|
<span className="sm:hidden">{isAnalyzing ? '...' : 'All'}</span>
|
|
</button>
|
|
|
|
{/* Terminal Button - View Live Logs */}
|
|
<button
|
|
onClick={() => setIsTerminalOpen(true)}
|
|
className={`
|
|
flex items-center justify-center p-1.5 sm:p-2 rounded-lg text-sm transition-all
|
|
${isAnalyzing
|
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 animate-pulse'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600'
|
|
}
|
|
`}
|
|
title="View live analysis terminal"
|
|
>
|
|
<Terminal className="w-4 h-4" />
|
|
</button>
|
|
|
|
<div className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-green-50 dark:bg-green-900/30 rounded-lg cursor-pointer hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors" onClick={() => setFilter('BUY')} title="Click to filter Buy stocks">
|
|
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-green-600 dark:text-green-400" aria-hidden="true" />
|
|
<span className="font-bold text-sm sm:text-base text-green-700 dark:text-green-400">{buy}</span>
|
|
<span className="text-xs text-green-600 dark:text-green-400 hidden sm:inline">Buy ({buyPct}%)</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-amber-50 dark:bg-amber-900/30 rounded-lg cursor-pointer hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors" onClick={() => setFilter('HOLD')} title="Click to filter Hold stocks">
|
|
<Minus className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-amber-600 dark:text-amber-400" aria-hidden="true" />
|
|
<span className="font-bold text-sm sm:text-base text-amber-700 dark:text-amber-400">{hold}</span>
|
|
<span className="text-xs text-amber-600 dark:text-amber-400 hidden sm:inline">Hold ({holdPct}%)</span>
|
|
</div>
|
|
<div className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 bg-red-50 dark:bg-red-900/30 rounded-lg cursor-pointer hover:bg-red-100 dark:hover:bg-red-900/50 transition-colors" onClick={() => setFilter('SELL')} title="Click to filter Sell stocks">
|
|
<TrendingDown className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-red-600 dark:text-red-400" aria-hidden="true" />
|
|
<span className="font-bold text-sm sm:text-base text-red-700 dark:text-red-400">{sell}</span>
|
|
<span className="text-xs text-red-600 dark:text-red-400 hidden sm:inline">Sell ({sellPct}%)</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="mt-4">
|
|
<div className="flex h-1.5 rounded-full overflow-hidden bg-gray-100 dark:bg-slate-700/50">
|
|
<div className="transition-all duration-500 rounded-l-full" style={{ width: `${buyPct}%`, background: 'linear-gradient(90deg, #10b981, #059669)' }} />
|
|
<div className="transition-all duration-500" style={{ width: `${holdPct}%`, background: 'linear-gradient(90deg, #f59e0b, #d97706)' }} />
|
|
<div className="transition-all duration-500 rounded-r-full" style={{ width: `${sellPct}%`, background: 'linear-gradient(90deg, #ef4444, #dc2626)' }} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mock Data Indicator */}
|
|
{isUsingMockData && (
|
|
<div className="mt-3 flex items-center gap-2 px-3 py-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
|
<AlertCircle className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
|
<span className="text-xs text-amber-700 dark:text-amber-300">
|
|
Using demo data. Run "Analyze All" or start the backend server for real AI recommendations.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Analysis Progress Banner */}
|
|
{isAnalyzing && analysisProgress && (
|
|
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-800">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="w-4 h-4 animate-spin text-blue-600 dark:text-blue-400" />
|
|
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
|
{isCancelling ? 'Cancelling...' : (
|
|
<>
|
|
Analyzing{' '}
|
|
{analysisProgress.current_symbols?.length > 0
|
|
? analysisProgress.current_symbols.join(', ')
|
|
: analysisProgress.current_symbol || 'stocks'}
|
|
...
|
|
</>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-blue-600 dark:text-blue-400">
|
|
{analysisProgress.completed + analysisProgress.failed} / {analysisProgress.total} stocks
|
|
{analysisProgress.skipped ? ` (${analysisProgress.skipped} skipped)` : ''}
|
|
</span>
|
|
<button
|
|
onClick={handleCancelAnalysis}
|
|
disabled={isCancelling}
|
|
className={`
|
|
flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all
|
|
${isCancelling
|
|
? 'bg-gray-200 text-gray-500 cursor-not-allowed dark:bg-gray-700 dark:text-gray-400'
|
|
: 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
|
}
|
|
`}
|
|
title="Cancel analysis"
|
|
>
|
|
<Square className="w-3 h-3" />
|
|
{isCancelling ? 'Cancelling...' : 'Cancel'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="w-full bg-blue-200 dark:bg-blue-800 rounded-full h-2">
|
|
<div
|
|
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${analysisProgress.total > 0 ? ((analysisProgress.completed + analysisProgress.failed) / analysisProgress.total) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
{analysisProgress.failed > 0 && (
|
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
|
{analysisProgress.failed} failed
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* How It Works Section */}
|
|
<HowItWorks collapsed={true} />
|
|
|
|
{/* Top Picks and Avoid Section - Side by Side Compact */}
|
|
<div className="grid lg:grid-cols-2 gap-4">
|
|
<TopPicks picks={recommendation.top_picks} />
|
|
<StocksToAvoid stocks={recommendation.stocks_to_avoid} />
|
|
</div>
|
|
|
|
{/* All Stocks Section with Integrated Filter */}
|
|
<section className="card">
|
|
<div className="p-4 border-b border-gray-100/80 dark:border-slate-700/40">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
<h2 className="font-display font-bold text-gray-900 dark:text-gray-100 text-sm tracking-tight">
|
|
{isAnalyzing ? `All 50 Stocks (${analysisProgress?.completed || 0} analyzed)` : `All ${total} Stocks`}
|
|
</h2>
|
|
</div>
|
|
<div className="flex gap-1.5" role="group" aria-label="Filter stocks by recommendation">
|
|
<button
|
|
onClick={() => setFilter('ALL')}
|
|
aria-pressed={filter === 'ALL'}
|
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
|
filter === 'ALL'
|
|
? 'bg-nifty-600 text-white shadow-sm'
|
|
: 'bg-gray-100 dark:bg-slate-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-500'
|
|
}`}
|
|
>
|
|
All ({total})
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter('BUY')}
|
|
aria-pressed={filter === 'BUY'}
|
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
|
filter === 'BUY'
|
|
? 'bg-green-600 text-white shadow-sm'
|
|
: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/50'
|
|
}`}
|
|
>
|
|
Buy ({buy})
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter('HOLD')}
|
|
aria-pressed={filter === 'HOLD'}
|
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
|
filter === 'HOLD'
|
|
? 'bg-amber-600 text-white shadow-sm'
|
|
: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 hover:bg-amber-100 dark:hover:bg-amber-900/50'
|
|
}`}
|
|
>
|
|
Hold ({hold})
|
|
</button>
|
|
<button
|
|
onClick={() => setFilter('SELL')}
|
|
aria-pressed={filter === 'SELL'}
|
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
|
filter === 'SELL'
|
|
? 'bg-red-600 text-white shadow-sm'
|
|
: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/50'
|
|
}`}
|
|
>
|
|
Sell ({sell})
|
|
</button>
|
|
<span className="mx-0.5 text-gray-300 dark:text-gray-600">|</span>
|
|
<button
|
|
onClick={() => setSortBy(sortBy === 'rank' ? 'symbol' : 'rank')}
|
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-all focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-1 dark:focus:ring-offset-slate-800 ${
|
|
sortBy === 'rank'
|
|
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
|
|
: 'bg-gray-100 dark:bg-slate-600 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-slate-500'
|
|
}`}
|
|
>
|
|
{sortBy === 'rank' ? '#Rank' : 'A-Z'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* Search Bar */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by symbol or company name..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-9 py-2 text-sm rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:border-transparent"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-2 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2 max-h-[400px] overflow-y-auto" role="list" aria-label="Stock recommendations list">
|
|
{filteredItems.map((item) => {
|
|
// COMPLETED with analysis data: clickable link
|
|
if (item.liveState === 'completed' && item.analysis) {
|
|
const backtest = isUsingMockData ? getStaticBacktestResult(item.symbol) : null;
|
|
const trend = item.analysis.decision === 'BUY' ? 'up' : item.analysis.decision === 'SELL' ? 'down' : 'flat';
|
|
return (
|
|
<Link
|
|
key={item.symbol}
|
|
to={`/stock/${item.symbol}`}
|
|
className="card-hover p-2 group relative overflow-hidden"
|
|
role="listitem"
|
|
>
|
|
{backtest && (
|
|
<div className="absolute inset-0 opacity-[0.06]">
|
|
<BackgroundSparkline data={backtest.price_history.slice(-15)} trend={trend} />
|
|
</div>
|
|
)}
|
|
<div className="relative z-10">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<RankBadge rank={item.analysis.rank} size="small" />
|
|
<span className="font-semibold text-sm text-gray-900 dark:text-gray-100">{item.symbol}</span>
|
|
<DecisionBadge decision={item.analysis.decision} size="small" />
|
|
</div>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{item.company_name}</p>
|
|
{item.analysis.hold_days != null && item.analysis.hold_days > 0 && item.analysis.decision !== 'SELL' && (
|
|
<div className="mt-1">
|
|
<HoldDaysBadge holdDays={item.analysis.hold_days} decision={item.analysis.decision} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// COMPLETED but data not re-fetched yet (brief transition)
|
|
if (item.liveState === 'completed' && !item.analysis) {
|
|
return (
|
|
<div key={item.symbol} className="p-2 rounded-xl border border-green-200 dark:border-green-800 bg-green-50/30 dark:bg-green-900/10" role="listitem">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<span className="font-semibold text-sm text-green-700 dark:text-green-300">{item.symbol}</span>
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400">
|
|
Done
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-green-500 dark:text-green-400 truncate">{item.company_name}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ANALYZING: shimmer effect with step progress
|
|
if (item.liveState === 'analyzing') {
|
|
const progress = analysisProgress?.stock_progress?.[item.symbol];
|
|
const stepsDone = progress?.done ?? 0;
|
|
const stepsTotal = progress?.total ?? 12;
|
|
return (
|
|
<div key={item.symbol} className="p-2 rounded-xl border border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/20 overflow-hidden relative" role="listitem">
|
|
<div className="absolute inset-0 shimmer-effect" />
|
|
<div className="relative z-10">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<span className="font-semibold text-sm text-blue-700 dark:text-blue-300">{item.symbol}</span>
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 gap-0.5">
|
|
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
|
<span className="hidden sm:inline">Analyzing</span>
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-blue-500 dark:text-blue-400 truncate">{item.company_name}</p>
|
|
{/* Step progress bar */}
|
|
<div className="mt-1.5 flex items-center gap-1.5">
|
|
<div className="flex-1 h-1 bg-blue-200 dark:bg-blue-800 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-1 bg-blue-500 dark:bg-blue-400 rounded-full transition-all duration-500"
|
|
style={{ width: `${stepsTotal > 0 ? (stepsDone / stepsTotal) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-blue-500 dark:text-blue-400 whitespace-nowrap">
|
|
{stepsDone}/{stepsTotal}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// FAILED: error state
|
|
if (item.liveState === 'failed') {
|
|
return (
|
|
<div key={item.symbol} className="p-2 rounded-xl border border-red-200 dark:border-red-800 bg-red-50/30 dark:bg-red-900/10 opacity-75" role="listitem">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<span className="font-semibold text-sm text-red-700 dark:text-red-400">{item.symbol}</span>
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400 gap-0.5">
|
|
<AlertCircle className="w-2.5 h-2.5" />
|
|
Failed
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-red-500 dark:text-red-400 truncate">{item.company_name}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// PENDING: grayed out, waiting
|
|
return (
|
|
<div key={item.symbol} className="p-2 rounded-xl border border-gray-200 dark:border-slate-700 bg-gray-50/50 dark:bg-slate-800/50 opacity-50" role="listitem">
|
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
<span className="font-semibold text-sm text-gray-400 dark:text-gray-500">{item.symbol}</span>
|
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-gray-500">
|
|
Queued
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-400 dark:text-gray-600 truncate">{item.company_name}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{filteredItems.length === 0 && (
|
|
<div className="p-8 text-center">
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">No stocks match the selected filter.</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Compact CTA */}
|
|
<Link
|
|
to="/history"
|
|
className="flex items-center justify-between p-4 rounded-xl text-white group focus:outline-none focus:ring-2 focus:ring-nifty-500 focus:ring-offset-2 transition-all hover:shadow-lg"
|
|
style={{
|
|
background: 'linear-gradient(135deg, #0284c7, #0369a1)',
|
|
boxShadow: '0 2px 8px rgba(2, 132, 199, 0.25)',
|
|
}}
|
|
aria-label="View historical stock recommendations"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<History className="w-5 h-5 opacity-80" aria-hidden="true" />
|
|
<span className="font-display font-bold tracking-tight">View Historical Recommendations</span>
|
|
</div>
|
|
<ChevronRight className="w-5 h-5 group-hover:translate-x-1 transition-transform opacity-80" aria-hidden="true" />
|
|
</Link>
|
|
|
|
{/* Terminal Modal */}
|
|
<TerminalModal
|
|
isOpen={isTerminalOpen}
|
|
onClose={() => setIsTerminalOpen(false)}
|
|
isAnalyzing={isAnalyzing}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|