'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 */}
{/* 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
setAsset('BTC')}>
BTC
setAsset('ETH')}>
ETH
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
setTimeframe(t)}>{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 })}`
: '—'}
{isConnected ? (
Account
Wallet
{address ? `${address.slice(0, 6)}…${address.slice(-4)}` : '—'}
Settings
{hasPerformanceData ? 'Loaded' : 'Not loaded'}
{hasPerformanceData && (
Win rate
{`${(winRate * 100).toFixed(1)}%`}
)}
) : (
Get started
{([
{ icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed' },
{ icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds' },
{ icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access' },
] as const).map(({ icon, head, sub }) => (
))}
)}
{/* KOL divergence hook — highest-conviction signal type */}
{(kolDivergences.length > 0 || (kolDigest && kolDigest.tickers.length > 0)) && (
{/* Latest divergence — the money signal */}
{kolDivergences.filter(d => d.signal_type === 'divergence').slice(0, 1).map(d => (
⚠️ DIVERGENCE
{/* post_at = when the KOL actually published. created_at
is the DB write time, which a backfill/late scan can
set to "now", making an old event look fresh. */}
{Math.round((Date.now() - new Date(d.post_at).getTime()) / 864e5)}d ago
@{d.handle} said {d.post_action}
{' '}on {d.ticker}
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null ? ` → $${(d.usd_after / 1000).toFixed(0)}k on-chain` : ''}
))}
{/* Top digest tickers */}
{kolDigest && kolDigest.tickers.slice(0, 2).map(t => (
{t.ticker}
{t.kol_count} KOLs
{t.dominant_action.toUpperCase()}
))}
)}
{/* 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}{' '}
setChartReload(n => n + 1)}>Retry
)}
{/* 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
{ setSelectedDayPosts(null); setSelectedPostId(null) }}
style={{ width:26, height:26, borderRadius:'50%' }}>
{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.text}
{/* 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)} />
) : (
)}
)
}