'use client' import { useState, useEffect } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useParams } from 'next/navigation' import { useLocale } from 'next-intl' import { useAccount } from 'wagmi' import type { TrumpPost, BotPerformance, Candle } from '@/types' import { useDashboardStore } from '@/store/dashboard' import { usePriceSocket } from '@/lib/useRealtimeData' import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, getKolDivergence, getKolDigest, type MacroSnapshot } from '@/lib/api' import type { KolDivergence, KolDigest } from '@/types' import { getCachedViewEnvelope } from '@/lib/signedRequest' import { swrFetch, invalidate as invalidateCache } from '@/lib/cache' import PostRow, { SignalPill, SourceIcon, SOURCE_DISPLAY, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards' import OpenPositions from '@/components/positions/OpenPositions' import PageHint from '@/components/ui/PageHint' import AnimatedNumber from '@/components/ui/AnimatedNumber' // Heavy components — lazy-loaded so they don't bloat the initial JS bundle. // ChartPanel pulls in lightweight-charts (~200KB gz); split it out. const ChartPanel = dynamic(() => import('@/components/dashboard/ChartPanel'), { ssr: false, loading: () =>
, }) interface Props { initialPosts: TrumpPost[] } // ── Inline post detail panel shown in the right rail ────────────────────────── function PostDetail({ post, onClose }: { post: TrumpPost; onClose: () => void }) { const impact = post.price_impact function fmtImpactPct(v: number | null | undefined) { if (v == null || isNaN(v)) return '—' const s = Math.abs(v).toFixed(2) + '%' return v >= 0 ? '+' + s : '-' + s } return (
{/* header */}
Signal detail
{/* source + time */}
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
·
{/* post text */}

{post.text}

{/* signal + sentiment */}
{post.sentiment}
{/* AI confidence */}
AI confidence {post.ai_confidence}%
{/* AI reasoning */} {post.ai_reasoning && ( <>
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
{post.ai_reasoning}
)} {/* Price impact grid */} {impact && ( <>
Price impact · {impact.asset}
{([['m5', '5m'], ['m15', '15m'], ['m1h', '1h']] as const).map(([key, label]) => { const v = impact[key] const correct = impact[`correct_${key}` as 'correct_m5' | 'correct_m15' | 'correct_m1h'] return (
{label}
= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600 }}> {fmtImpactPct(v)}
{correct != null && (
{correct ? '✓' : '✗'}
)}
) })}
)}
) } // ── Empty state for when nothing is selected ────────────────────────────────── function SelectHint() { return (

Click any marker on the chart or a post below to see details

) } // ── Main dashboard ───────────────────────────────────────────────────────────── export default function DashboardClient({ initialPosts }: Props) { const intlLocale = useLocale() const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore() function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') } const { address, isConnected } = useAccount() const params = useParams() const locale = (typeof params?.locale === 'string' ? params.locale : 'en') const [posts, setPosts] = useState(initialPosts) const [performance, setPerformance] = useState(undefined) const [candles, setCandles] = useState([]) const [chartErr, setChartErr] = useState('') const [chartReload, setChartReload] = useState(0) const [selectedPostId, setSelectedPostId] = useState(null) const [macro, setMacro] = useState(null) const [kolDivergences, setKolDivergences] = useState([]) const [kolDigest, setKolDigest] = useState(null) // For 1D: show all posts from selected day const [selectedDayPosts, setSelectedDayPosts] = useState(null) useEffect(() => { // B42: cancel the in-flight getUserPublic if the wallet switches again // before it resolves — prevents old wallet's data polluting new wallet's state. let cancelled = false if (!isConnected || !address) { setSubscribed(false) setHlApiKeySet(false) setBotReadiness('unknown') setPerformance(undefined) return () => { cancelled = true } } // Clear account-scoped bot state immediately (setWallet already resets these // via store, but DashboardClient may be rendered without a wallet switch — // keep explicit resets here for clarity and safety). setSubscribed(false) setHlApiKeySet(false) setBotReadiness('unknown') // `snapAddr !== address` would be a stale-closure trap: both are the same // closed-over value. Use `cancelled` (set by effect cleanup) as the sole guard. getUserPublic(address.toLowerCase()) .then((user) => { if (cancelled) return // effect cleaned up = wallet changed or unmounted setSubscribed(user.active) setHlApiKeySet(user.hl_api_key_set) setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown') setPaperMode(!!user.paper_mode) }) .catch(() => {}) return () => { cancelled = true } }, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness]) useEffect(() => { if (!isConnected || !address) { setPerformance(undefined) return } let cancelled = false ;(async () => { try { const env = getCachedViewEnvelope('view_performance', address) ?? getCachedViewEnvelope('view_user', address) if (!env) { if (!cancelled) setPerformance(undefined) return } const data = await getPerformance(address, env) if (!cancelled) setPerformance(data) } catch { if (!cancelled) setPerformance(undefined) } })() return () => { cancelled = true } }, [address, isConnected]) const [freshPostId, setFreshPostId] = useState(null) usePriceSocket({ onPrice: (a, price) => setLivePrice(a, price), onNewPost: (post) => { const p = post as TrumpPost // Dedup: WS may resend a post already in initialPosts or a prior push. setPosts((prev) => prev.some(x => x.id === p.id) ? prev : [p, ...prev].slice(0, 500)) setFreshPostId(p.id) // Keep a ref to the timer so we can cancel it if the component unmounts // before it fires (avoids setState-after-unmount warning). const timer = setTimeout(() => setFreshPostId((id) => (id === p.id ? null : id)), 1400) return () => clearTimeout(timer) // returned but not used by usePriceSocket — see L1 note }, }) useEffect(() => { let cancelled = false setCandles([]) setChartErr('') const priceKey = `prices-${asset}-${timeframe}` if (chartReload > 0) invalidateCache(priceKey) swrFetch( priceKey, 90_000, () => getPrices(asset, timeframe), fresh => { if (!cancelled) { setCandles(fresh); setChartErr('') } }, ) .then(c => { if (!cancelled) { setCandles(c); setChartErr('') } }) .catch(e => { if (!cancelled) setChartErr(e instanceof Error ? e.message : 'Failed to load price data') }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [asset, timeframe, chartReload]) useEffect(() => { let alive = true function load() { swrFetch( 'macro-snapshot', 10 * 60_000, () => getMacroSnapshot(), fresh => { if (alive) setMacro(fresh) }, ) .then((snap) => { if (alive) setMacro(snap) }) .catch(() => {}) } load() const id = setInterval(load, 10 * 60_000) return () => { alive = false; clearInterval(id) } }, []) // KOL divergence + digest — loaded once, 30 min cache (changes daily) useEffect(() => { let alive = true // KOL ingestion is daily and sparse — a 7d window is frequently empty // (e.g. 7d=0 while 30d=196), which hid the whole sidebar card. Use a 30d // window so the Overview reflects the data that actually exists. swrFetch('kol-divergence-30d', 30 * 60_000, () => getKolDivergence({ days: 30 }), f => { if (alive) setKolDivergences(f.items ?? []) }) .then(r => { if (alive) setKolDivergences(r.items ?? []) }) .catch(() => {}) swrFetch('kol-digest-30d', 30 * 60_000, () => getKolDigest(30), f => { if (alive) setKolDigest(f) }) .then(r => { if (alive) setKolDigest(r) }) .catch(() => {}) return () => { alive = false } }, []) const selectedPost = posts.find(p => p.id === selectedPostId) ?? null // Show actionable signals first (buy/short), then most recent hold/neutral. // Cap at 8 total so the list doesn't get too long. const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, 4) const recentOthers = posts.filter(p => p.signal !== 'buy' && p.signal !== 'short').slice(0, 4) const recentPosts = [...actionable, ...recentOthers].slice(0, 8) const lastCandle = candles[candles.length - 1] // Live price beats the most recent candle close — the candle is only // refreshed when the user changes asset/timeframe, so without WS the number // is frozen at page load. const livePrice = livePrices[asset] const displayPrice = livePrice ?? lastCandle?.close ?? null // 24-hour change. Picking by candle index is wrong (200×4H = 33 days). // Find the candle whose `time` is closest to (now − 24h) and use its open. const now = lastCandle ? lastCandle.time : Math.floor(Date.now() / 1000) const target24h = now - 24 * 3600 let baseline24h: typeof lastCandle | undefined if (candles.length) { baseline24h = candles[0] for (const c of candles) { if (c.time <= target24h) baseline24h = c else break } } const priceChange = displayPrice != null && baseline24h ? ((displayPrice - baseline24h.open) / baseline24h.open) * 100 : 0 const totalPosts = posts.length const todayKey = new Date().toISOString().slice(0, 10) const signalsToday = posts.filter(p => p.published_at?.slice(0, 10) === todayKey).length const actionablePosts = posts.filter(p => p.signal === 'buy' || p.signal === 'short').length const trumpActionable = posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length const macroActionable = posts.filter(p => ((p.source || '') === 'btc_bottom_reversal' || (p.source || '') === 'funding_reversal') && (p.signal === 'buy' || p.signal === 'short')).length // Use actual KOL divergence count (divergence-flagged items from the API), // not a source==='kol' post count (which never matches — source is 'substack'/'blog'/etc.). const kolMentions = kolDivergences.filter(d => d.signal_type === 'divergence').length const winRate = performance?.win_rate ?? 0 const netPnl = performance?.net_pnl_usd ?? 0 const hasPriceData = candles.length > 0 const hasPerformanceData = Boolean(performance) const macroScore = macro?.composite_score ?? null const macroRegime = macro?.regime_label ?? null const macroPct = macroScore == null ? 50 : Math.max(0, Math.min(100, (macroScore + 100) / 2)) // Derive tone from the backend's regime_label, NOT a re-thresholded score. // The backend uses ±20 for BULLISH/BEARISH; re-deriving with ±15 here made // a score of e.g. 15.2 read "Supportive" in the card while the same card's // regime label said NEUTRAL. Trusting regime_label keeps them consistent. const macroTone = macroRegime == null ? (macroScore == null ? 'neutral' : 'neutral') : macroRegime === 'BULL' || macroRegime === 'BULLISH' ? 'bull' : macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear' : 'neutral' const macroSummary = macroScore == null ? 'Daily macro composite not loaded yet.' : macroTone === 'bull' ? 'Supportive backdrop — trend setups have room.' : macroTone === 'bear' ? 'Defensive backdrop — preserve size, downside moves are cleaner.' : 'Mixed backdrop — context only, not a directional trigger.' return (

{isZh ? '信号总览' : 'Signal monitor'}

0 ? `${actionablePosts} actionable · ${totalPosts} tracked` : undefined}> Trump · Macro · KOL divergence — live.
Live
{/* Open positions — what's on the book right now. Renders only when a subscribed wallet is connected, so guests see the normal feed. */}
Market and macro
= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h
{asset} · live signal context
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => ( ))}
Macro composite
today · 8 indicators
{macroSummary}
{macroScore == null ? '—' : `${macroScore > 0 ? '+' : ''}${macroScore.toFixed(1)}`}
{macroRegime ?? 'Waiting'}
−100 bear 0 neutral +100 bull
{/* ── Unified stats row ─────────────────────────────────────────── */}
{([ { label: 'Trump signals', value: trumpActionable, href: `/${locale}/trump` }, { label: 'Macro signals', value: macroActionable, href: `/${locale}/macro` }, { label: 'KOL divergence', value: kolMentions, href: `/${locale}/kol` }, ] as const).map(({ label, value, href }) => { const empty = value === 0 const inner = ( <> {label} {empty ? '—' : value} ) return {inner} })}
My P&L · 30d = 0 ? 'var(--up)' : 'var(--down)') : 'var(--ink-4)', }}> {hasPerformanceData ? `${netPnl >= 0 ? '+' : '−'}$${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}` : '—'}
{/* Left: Chart + signal stream */}
Price · {asset}
= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h
Live chart with signal markers
{chartErr && (
⚠️ {asset} {timeframe} chart failed to load — {chartErr}{' '}
)}
{/* Loading overlay — shown while candles are fetching (candles=[] and no error). Prevents the user from seeing a blank lightweight-charts canvas. */} {!hasPriceData && !chartErr && (
Loading chart…
)} { setSelectedDayPosts(null) setSelectedPostId(id) }} onSelectDayPosts={(dayPosts) => { setSelectedDayPosts(dayPosts) setSelectedPostId(null) }} />
Buy signal
Short signal
Hold / filtered
{asset === 'BTC' &&
Macro reversal highlight
}
Binance candles via backend API · click marker to inspect
{/* Recent signals list */}

Recent signals

{actionable.length > 0 ? `${actionable.length} actionable · ` : ''} {`${totalPosts} tracked`}
{recentPosts.map(p => (
{ // Clear the day-list view first: the right panel renders // selectedDayPosts with higher priority than selectedPost, // so without this the detail never appears while a candle's // multi-post list is open. setSelectedDayPosts(null) setSelectedPostId(selectedPostId === p.id ? null : p.id) }} />
))}
{/* Right rail */}
{/* Day-view: all posts from clicked day */} {selectedDayPosts ? (
{selectedDayPosts.length} posts · this candle
{selectedDayPosts.map(p => { const expanded = selectedPostId === p.id const impact = p.price_impact function fmtImpactPct(v: number | null | undefined) { if (v == null || isNaN(v)) return '—' const s = Math.abs(v).toFixed(2) + '%' return v >= 0 ? '+' + s : '-' + s } return (
{/* collapsed row — always visible */}
setSelectedPostId(expanded ? null : p.id)} style={{ padding:12, cursor:'pointer', background: expanded ? 'var(--bg-sunk)' : 'transparent' }}>
{p.sentiment}

{p.text}

{p.ai_confidence}% conf
{/* expanded detail */} {expanded && (
{p.ai_reasoning && ( <>
AI reasoning
{p.ai_reasoning}
)} {impact && ( <>
Price impact · {impact.asset}
{(['m5','m15','m1h'] as const).map((key) => { const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h' const v = impact[key] const correct = impact[`correct_${key}` as 'correct_m5'|'correct_m15'|'correct_m1h'] return (
{label}
=0?'up':'down'}`} style={{ fontSize:13, fontWeight:600 }}> {fmtImpactPct(v)}
{correct != null && (
{correct ? '✓' : '✗'}
)}
) })}
)}
)}
) })}
) : selectedPost ? ( setSelectedPostId(null)} /> ) : ( )}
) }