fix: make AI stock analysis lazy-load instead of blocking page render
The AI analysis card now fetches asynchronously on the client side after the page mounts, instead of blocking the entire server-rendered page until the LLM returns. A loading skeleton with animate-pulse is shown while the analysis is being generated. - Convert StockAIAnalysisCard to client component with useEffect fetch - Page now only awaits fast sentiment insights server-side - Add getAIAnalysis server action for client-side AI analysis requests - Show loading skeleton and error states in the AI analysis card Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0ec58caa62
commit
c3f5aba330
|
|
@ -14,7 +14,7 @@ import {
|
||||||
import { auth } from '@/lib/better-auth/auth';
|
import { auth } from '@/lib/better-auth/auth';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { isStockInWatchlist } from '@/lib/actions/watchlist.actions';
|
import { isStockInWatchlist } from '@/lib/actions/watchlist.actions';
|
||||||
import { getStockDetailInsights } from '@/lib/actions/stock-analysis.actions';
|
import { getStockSentimentInsights } from '@/lib/actions/adanos.actions';
|
||||||
import { formatSymbolForTradingView } from '@/lib/utils';
|
import { formatSymbolForTradingView } from '@/lib/utils';
|
||||||
|
|
||||||
export default async function StockDetails({ params }: StockDetailsPageProps) {
|
export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
|
|
@ -26,11 +26,10 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
headers: await headers()
|
headers: await headers()
|
||||||
});
|
});
|
||||||
const userId = session?.user?.id;
|
const userId = session?.user?.id;
|
||||||
const [isInWatchlist, stockInsights] = await Promise.all([
|
const [isInWatchlist, sentimentInsights] = await Promise.all([
|
||||||
userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false),
|
userId ? isStockInWatchlist(userId, symbol) : Promise.resolve(false),
|
||||||
getStockDetailInsights(symbol),
|
getStockSentimentInsights(symbol),
|
||||||
]);
|
]);
|
||||||
const { sentimentInsights, aiAnalysis } = stockInsights;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen p-4 md:p-6 lg:p-8">
|
<div className="flex min-h-screen p-4 md:p-6 lg:p-8">
|
||||||
|
|
@ -65,13 +64,16 @@ export default async function StockDetails({ params }: StockDetailsPageProps) {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<WatchlistButton
|
<WatchlistButton
|
||||||
symbol={symbol.toUpperCase()}
|
symbol={symbol.toUpperCase()}
|
||||||
company={aiAnalysis?.companyName ?? sentimentInsights?.companyName ?? symbol.toUpperCase()}
|
company={sentimentInsights?.companyName ?? symbol.toUpperCase()}
|
||||||
isInWatchlist={isInWatchlist}
|
isInWatchlist={isInWatchlist}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StockAIAnalysisCard analysis={aiAnalysis} />
|
<StockAIAnalysisCard
|
||||||
|
symbol={symbol.toUpperCase()}
|
||||||
|
companyName={sentimentInsights?.companyName ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
<StockSentimentCard insight={sentimentInsights} />
|
<StockSentimentCard insight={sentimentInsights} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAIAnalysis } from '@/lib/actions/stock-analysis.actions';
|
||||||
import type { StockAIAnalysis } from '@/lib/actions/stock-analysis.helpers';
|
import type { StockAIAnalysis } from '@/lib/actions/stock-analysis.helpers';
|
||||||
|
|
||||||
interface StockAIAnalysisCardProps {
|
interface StockAIAnalysisCardProps {
|
||||||
analysis: StockAIAnalysis | null;
|
symbol: string;
|
||||||
|
companyName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStanceClasses(stance: StockAIAnalysis['stance']) {
|
function getStanceClasses(stance: StockAIAnalysis['stance']) {
|
||||||
|
|
@ -30,17 +35,88 @@ function renderList(title: string, items: string[]) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StockAIAnalysisCard({ analysis }: StockAIAnalysisCardProps) {
|
function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-gray-800 bg-gray-950/40 p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col gap-4 animate-pulse">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-3 w-32 rounded bg-gray-700" />
|
||||||
|
<div className="mt-2 h-6 w-48 rounded bg-gray-700" />
|
||||||
|
<div className="mt-3 h-4 w-full rounded bg-gray-700" />
|
||||||
|
<div className="mt-2 h-4 w-3/4 rounded bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-6 w-20 rounded-full bg-gray-700" />
|
||||||
|
<div className="h-6 w-28 rounded-full bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<div key={i} className="rounded-xl border border-gray-800 bg-black/20 p-4">
|
||||||
|
<div className="h-3 w-20 rounded bg-gray-700" />
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="h-3 w-full rounded bg-gray-700" />
|
||||||
|
<div className="h-3 w-5/6 rounded bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-72 rounded bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState() {
|
||||||
|
return (
|
||||||
|
<section className="rounded-2xl border border-gray-800 bg-gray-950/40 p-5 backdrop-blur-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-500">AI Stock Analysis</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold text-white">Professional research note unavailable</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-gray-400">
|
||||||
|
The stock dashboard data loaded, but an AI research note could not be generated right now.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StockAIAnalysisCard({ symbol, companyName }: StockAIAnalysisCardProps) {
|
||||||
|
const [analysis, setAnalysis] = useState<StockAIAnalysis | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function fetchAnalysis() {
|
||||||
|
try {
|
||||||
|
const result = await getAIAnalysis(symbol, companyName);
|
||||||
|
if (!cancelled) {
|
||||||
|
setAnalysis(result);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setAnalysis(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAnalysis();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [symbol, companyName]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!analysis) {
|
if (!analysis) {
|
||||||
return (
|
return <ErrorState />;
|
||||||
<section className="rounded-2xl border border-gray-800 bg-gray-950/40 p-5 backdrop-blur-sm">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-gray-500">AI Stock Analysis</p>
|
|
||||||
<h2 className="mt-2 text-xl font-semibold text-white">Professional research note unavailable</h2>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-gray-400">
|
|
||||||
The stock dashboard data loaded, but an AI research note could not be generated right now.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 493 KiB |
|
|
@ -102,3 +102,62 @@ export async function getStockDetailInsights(symbol: string): Promise<StockDetai
|
||||||
tags: [`stock-analysis:${normalizedSymbol}`],
|
tags: [`stock-analysis:${normalizedSymbol}`],
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAIAnalysis(
|
||||||
|
symbol: string,
|
||||||
|
companyName?: string | null,
|
||||||
|
): Promise<StockAIAnalysis | null> {
|
||||||
|
const normalizedSymbol = symbol.trim().toUpperCase();
|
||||||
|
|
||||||
|
if (!normalizedSymbol || !hasConfiguredAIProvider()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return unstable_cache(
|
||||||
|
async () => {
|
||||||
|
const [quote, profile, basicFinancials, news] = await Promise.all([
|
||||||
|
getQuote(normalizedSymbol),
|
||||||
|
getCompanyProfile(normalizedSymbol),
|
||||||
|
getBasicFinancials(normalizedSymbol),
|
||||||
|
getCompanyNews(normalizedSymbol),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasAnalysisContext =
|
||||||
|
Boolean(quote) ||
|
||||||
|
Boolean(profile) ||
|
||||||
|
Boolean(basicFinancials?.metric && Object.keys(basicFinancials.metric).length > 0) ||
|
||||||
|
news.length > 0;
|
||||||
|
|
||||||
|
if (!hasAnalysisContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await callAIProviderWithFallback(
|
||||||
|
buildStockAnalysisPrompt({
|
||||||
|
symbol: normalizedSymbol,
|
||||||
|
quote,
|
||||||
|
profile,
|
||||||
|
basicFinancials,
|
||||||
|
sentimentInsights: null,
|
||||||
|
recentHeadlines: news.slice(0, 4).map(normalizeHeadline),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return parseStockAnalysisResponse(
|
||||||
|
response,
|
||||||
|
normalizedSymbol,
|
||||||
|
companyName ?? profile?.name ?? null,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to generate AI stock analysis for ${normalizedSymbol}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
['ai-stock-analysis', normalizedSymbol],
|
||||||
|
{
|
||||||
|
revalidate: 900,
|
||||||
|
tags: [`stock-analysis:${normalizedSymbol}`],
|
||||||
|
},
|
||||||
|
)();
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue