Files

925 lines
45 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useRef } 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, getPostsPage, 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 InfoTip from '@/components/ui/InfoTip'
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: () => <div style={{ height: 320, background: 'var(--bg-sunk)', borderRadius: 12, marginBottom: 16 }} />,
})
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 (
<div className="card" style={{ padding: 20 }}>
{/* header */}
<div className="row between" style={{ marginBottom: 14 }}>
<span className="tiny">Signal detail</span>
<button
className="icon-btn"
onClick={onClose}
style={{ width: 26, height: 26, borderRadius: '50%' }}
title="Close"
>
<svg width="10" height="10" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</button>
</div>
{/* source + time */}
<div className="row gap-s" style={{ marginBottom: 12 }}>
<SourceIcon source={post.source} />
<div>
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--mono)' }}>
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)' }}><TimeAgo iso={post.published_at} suffix=" ago" /> · <LocalDateTime iso={post.published_at} /></div>
</div>
</div>
{/* post text */}
<p style={{ fontSize: 14, lineHeight: 1.55, margin: '0 0 16px', color: 'var(--ink)' }}>{post.text}</p>
{/* signal + sentiment */}
<div className="row gap-s" style={{ marginBottom: 16 }}>
<SignalPill signal={post.signal} />
<span className={`chip ${post.sentiment === 'bullish' ? 'up' : post.sentiment === 'bearish' ? 'down' : 'neutral'}`}>
{post.sentiment}
</span>
</div>
{/* AI confidence */}
<div className="ai-metric-head">
<span>AI confidence</span>
<strong>{post.ai_confidence}%</strong>
</div>
<div className="confidence-bar" style={{ marginBottom: 16 }}>
<div style={{ width: post.ai_confidence + '%' }} />
</div>
{/* AI reasoning */}
{post.ai_reasoning && (
<>
<div className="ai-reasoning-label">
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
</div>
<div className="ai-reasoning-card scroll" style={{ marginBottom: 16 }}>
{post.ai_reasoning}
</div>
</>
)}
{/* Price impact grid */}
{impact && (
<>
<div className="tiny" style={{ marginBottom: 10 }}>Price impact · {impact.asset}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
{([['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 (
<div key={key} style={{
padding: 10,
background: 'var(--bg-sunk)',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--line)',
textAlign: 'center',
}}>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginBottom: 4 }}>{label}</div>
<div className={`delta ${(v ?? 0) >= 0 ? 'up' : 'down'}`} style={{ fontSize: 14, fontWeight: 600 }}>
{fmtImpactPct(v)}
</div>
{correct != null && (
<div style={{ fontSize: 10, marginTop: 4, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)
}
// ── Empty state for when nothing is selected ──────────────────────────────────
function SelectHint() {
return (
<div className="card" style={{ padding: 24, textAlign: 'center', color: 'var(--ink-3)' }}>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" style={{ margin: '0 auto 12px', display: 'block', opacity: 0.4 }}>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="2"/>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
<p style={{ fontSize: 13, margin: 0, lineHeight: 1.5 }}>
Click any marker on the chart or a post below to see details
</p>
</div>
)
}
// ── 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<TrumpPost[]>(initialPosts)
// Chart markers need actionable (buy/short) posts, but the 80-post
// first-paint slice is usually ALL hold/filtered — Trump posts are mostly
// off-topic, so the chart would render zero markers. Fetch signal posts
// separately and merge them in for the chart.
const [signalPosts, setSignalPosts] = useState<TrumpPost[]>([])
// Distinguishes "actionable fetch not done yet" from "loaded, zero
// actionable" — the auto-select effect waits on it before falling back.
const [signalPostsLoaded, setSignalPostsLoaded] = useState(false)
const [performance, setPerformance] = useState<BotPerformance | undefined>(undefined)
const [candles, setCandles] = useState<Candle[]>([])
const [hideFiltered, setHideFiltered] = useState(false)
const [chartErr, setChartErr] = useState('')
const [chartReload, setChartReload] = useState(0)
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
const railRef = useRef<HTMLDivElement>(null)
const [macro, setMacro] = useState<MacroSnapshot | null>(null)
const [kolDivergences, setKolDivergences] = useState<KolDivergence[]>([])
const [kolDigest, setKolDigest] = useState<KolDigest | null>(null)
// For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
// Server-side aggregate counts. The Overview headline numbers (total tracked,
// actionable, Trump/Macro signals) must reflect the WHOLE feed, not the
// 80-post first-paint slice — otherwise a site with 1108 posts / 25 actionable
// Trump signals showed "80 tracked" and "—". Loaded once from /posts-paged
// which returns server-computed totals + counts without shipping the rows.
const [serverCounts, setServerCounts] = useState<{
total: number; actionable: number; trump: number; macro: number
} | null>(null)
// Backend degraded state. When the process isn't the singleton leader it
// runs NO background tasks (no scrapers, price feeds, scheduler) — the data
// is frozen and "Live" would be a lie. /api/health/deep returns 503 + a body
// with { status, is_leader } in that case.
const [degraded, setDegraded] = useState(false)
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, setPaperMode])
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<number | null>(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 }
}, [asset, timeframe, chartReload])
// Auto-select the latest actionable signal once on load so the detail rail
// isn't an empty column until the user clicks something. Runs once; never
// re-selects after the user picks or closes a post.
const autoSelectedRef = useRef(false)
const skipRailScrollRef = useRef(false)
useEffect(() => {
if (autoSelectedRef.current) return
if (selectedPostId != null || selectedDayPosts) { autoSelectedRef.current = true; return }
const pool = [...posts, ...signalPosts]
const actionable = pool.filter(p => p.signal === 'buy' || p.signal === 'short')
// Prefer the latest actionable signal — but the actionable fetch is async,
// so wait for it before falling back to a (likely off-topic) recent post.
if (!actionable.length && !signalPostsLoaded) return
const pick = (actionable.length ? actionable : pool)
.slice()
.sort((a, b) => +new Date(b.published_at) - +new Date(a.published_at))[0]
if (!pick) return
autoSelectedRef.current = true
skipRailScrollRef.current = true // a load-time selection must not scroll the page
setSelectedPostId(pick.id)
}, [posts, signalPosts, signalPostsLoaded, selectedPostId, selectedDayPosts])
// On narrow viewports the layout stacks and the detail rail sits ABOVE the
// chart — a click on a post down in the list renders the detail card out of
// view and the selection looks like a no-op. Scroll it into view, but only
// when it's actually outside the viewport (no jump on desktop, where the
// rail is a sticky side column).
useEffect(() => {
if (selectedPostId == null && !selectedDayPosts) return
if (skipRailScrollRef.current) { skipRailScrollRef.current = false; return }
const el = railRef.current
if (!el) return
const r = el.getBoundingClientRect()
// The detail card renders at the TOP of the rail, so "visible" means the
// rail's top edge is on screen — a rail whose tail end pokes into the
// viewport still hides the card.
if (r.top < 0 || r.top > window.innerHeight - 120) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [selectedPostId, selectedDayPosts])
// Actionable posts for the chart markers (see signalPosts above).
useEffect(() => {
let alive = true
swrFetch(
'posts-actionable-chart',
90_000,
() => getPostsPage(100, 1, undefined, { signal: 'actionable' }),
fresh => { if (alive) setSignalPosts(fresh.items) },
)
.then(r => { if (alive) { setSignalPosts(r.items); setSignalPostsLoaded(true) } })
.catch(() => { if (alive) setSignalPostsLoaded(true) })
return () => { alive = false }
}, [])
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) }
}, [])
// Poll backend health so the "Live" chip can downgrade to "Delayed" when the
// process is a non-leader follower (read-only, no background tasks → data is
// frozen). The body is present on both 200 and 503, so read it regardless of
// status. Key ONLY on is_leader === false: a `degraded` status can also mean
// a single price feed briefly lagged, which would flap the chip even though
// the feed self-heals in seconds — that's noisier than it is useful.
useEffect(() => {
let alive = true
async function check() {
try {
const res = await fetch('/api/proxy/api/health/deep')
const body = await res.json().catch(() => null)
if (!alive || !body) return
setDegraded(body.is_leader === false)
} catch { /* network error — leave chip as-is */ }
}
check()
const id = setInterval(check, 60_000)
return () => { alive = false; clearInterval(id) }
}, [])
// Server-side aggregate counts for the Overview headline numbers. limit=1
// keeps the payload tiny — we only consume `total` and `counts`.
useEffect(() => {
let alive = true
;(async () => {
try {
const [global, trump, macro] = await Promise.all([
getPostsPage(1, 1),
getPostsPage(1, 1, 'truth'),
getPostsPage(1, 1, undefined, { sourceIn: ['btc_bottom_reversal', 'funding_reversal'] }),
])
if (!alive) return
setServerCounts({
total: global.total,
actionable: global.counts.actionable,
trump: trump.counts.actionable,
macro: macro.counts.actionable,
})
} catch { /* fall back to local-slice counts */ }
})()
return () => { alive = false }
}, [])
// 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 }
}, [])
// Union of the recent-posts slice and the separately-fetched actionable
// posts (deduped) — feeds the chart so signal markers always render, and
// the detail lookup so clicking one of those markers resolves.
const chartPosts = (() => {
const seen = new Set(posts.map(p => p.id))
return [...posts, ...signalPosts.filter(p => !seen.has(p.id))]
})()
const selectedPost = chartPosts.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. "Signals only" hides the
// hold/filtered (gray) posts entirely — same toggle as the Trump page.
const actionable = posts.filter(p => p.signal === 'buy' || p.signal === 'short').slice(0, hideFiltered ? 8 : 4)
const recentOthers = hideFiltered ? [] : 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
// Prefer server-computed totals (whole feed); fall back to the local 80-post
// slice only until serverCounts loads (or if that fetch failed).
const totalPosts = serverCounts?.total ?? 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 = serverCounts?.actionable ?? posts.filter(p => p.signal === 'buy' || p.signal === 'short').length
const trumpActionable = serverCounts?.trump ?? posts.filter(p => (p.source || '') === 'truth' && (p.signal === 'buy' || p.signal === 'short')).length
const macroActionable = serverCounts?.macro ?? 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 === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
: macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear'
: 'neutral' // covers NEUTRAL and the null/not-loaded case
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 (
<div className="page wide">
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '信号总览' : 'Signal monitor'}</h1>
<PageHint count={actionablePosts > 0 ? `${actionablePosts} actionable · ${totalPosts} tracked` : undefined}>
Trump · Macro · KOL divergence live.
</PageHint>
</div>
{degraded ? (
<span className="chip" title="Backend is running read-only (not the singleton leader) — no background tasks, values may be delayed."
style={{ color: 'var(--amber-ink)', borderColor: 'color-mix(in oklab, var(--amber) 35%, var(--line))' }}>
<span className="live-dot" style={{ background: 'var(--amber)' }} />Delayed
</span>
) : (
<span className="chip"><span className="live-dot" />Live</span>
)}
</div>
{/* Open positions — what's on the book right now. Renders only when
a subscribed wallet is connected, so guests see the normal feed. */}
<OpenPositions />
<div className="overview-shell">
<div className="overview-main">
<section className="overview-market-card">
<div className="overview-card-head">
<div>
<div className="overview-kicker">Market and macro</div>
<div className="overview-headline-row">
<AnimatedNumber
className="hero-value mono"
style={{ fontSize: 40 }}
value={displayPrice}
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
/>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
</div>
<div className="overview-market-subtitle">{asset} · live signal context</div>
</div>
</div>
<div className={`overview-macro-card ${macroTone}`}>
<div className="overview-macro-band">
<div className="overview-macro-copy">
<div className="overview-macro-title-row">
<div className="overview-macro-title">Macro composite</div>
<div className="overview-macro-stamp">today · 8 indicators</div>
</div>
<div className="overview-macro-text">{macroSummary}</div>
</div>
<div className="overview-macro-score">
<div className={`overview-score-value ${macroTone}`}>
{macroScore == null ? '—' : `${macroScore > 0 ? '+' : ''}${macroScore.toFixed(1)}`}
</div>
<div className={`overview-score-pill ${macroTone}`}>{macroRegime ?? 'Waiting'}</div>
</div>
</div>
<div className="overview-score-track">
<div className={`overview-score-needle ${macroTone}`} style={{ left: `calc(${macroPct}% - 11px)` }} />
</div>
<div className="overview-score-scale">
<span>100 bear</span>
<span>0 neutral</span>
<span>+100 bull</span>
</div>
</div>
</section>
{/* ── Unified stats row ─────────────────────────────────────────── */}
<div className="overview-stats-bar">
{([
{ 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 = (
<>
<span className="stats-bar-label">{label}</span>
<span className="stats-bar-value" style={{ color: empty ? 'var(--ink-4)' : 'var(--ink)' }}>
{empty ? '—' : value}
</span>
</>
)
return <Link key={label} href={href} className="stats-bar-item">{inner}</Link>
})}
<div className="stats-bar-item passive" style={{ borderRight: 'none' }}>
<span className="stats-bar-label">My P&amp;L · 30d</span>
<span className="stats-bar-value" style={{
color: hasPerformanceData ? (netPnl >= 0 ? 'var(--up)' : 'var(--down)') : 'var(--ink-4)',
}}>
{hasPerformanceData
? `${netPnl >= 0 ? '+' : ''}$${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}`
: '—'}
</span>
</div>
</div>
</div>
<aside className="overview-side">
{isConnected ? (
<section className="overview-side-card">
<div className="overview-kicker">Account</div>
<div className="overview-account-list">
<div className="overview-account-item">
<span>Wallet</span>
<strong className="mono">{address ? `${address.slice(0, 6)}${address.slice(-4)}` : '—'}</strong>
</div>
<div className="overview-account-item">
<span>Settings</span>
<strong>{hasPerformanceData ? 'Loaded' : 'Not loaded'}</strong>
</div>
{hasPerformanceData && (
<div className="overview-account-item">
<span>Win rate</span>
<strong>{`${(winRate * 100).toFixed(1)}%`}</strong>
</div>
)}
</div>
</section>
) : (
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
<div className="overview-kicker" style={{ marginBottom: 12 }}>Get started</div>
{([
{ icon: '📡', head: 'Free signals', sub: 'Trump · Macro · KOL — no account needed', href: `/${locale}/trump` },
{ icon: '🔔', head: 'Telegram alerts', sub: 'Signal fires → your phone in seconds', href: `/${locale}/settings` },
{ icon: '⚡', head: 'Auto-trade', sub: 'Trade-only key · no withdrawal access', href: `/${locale}/trades` },
] as const).map(({ icon, head, sub, href }) => (
<Link key={head} href={href} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12, textDecoration: 'none', color: 'inherit' }}>
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>{icon}</span>
<div>
<div style={{ fontSize: 12, fontWeight: 700, lineHeight: 1.3 }}>{head} <span style={{ color: 'var(--ink-4)', fontWeight: 400 }}></span></div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4, marginTop: 1 }}>{sub}</div>
</div>
</Link>
))}
</section>
)}
{/* KOL divergence hook — highest-conviction signal type */}
{(kolDivergences.length > 0 || (kolDigest && kolDigest.tickers.length > 0)) && (
<section className="overview-side-card" style={{ padding: '14px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div className="overview-kicker">KOL intel · 30d</div>
<a href={`/${locale}/kol`} style={{ fontSize: 10, color: 'var(--amber-ink)', textDecoration: 'none', fontWeight: 700 }}>
Full feed
</a>
</div>
{/* Latest divergence — the money signal */}
{kolDivergences.filter(d => d.signal_type === 'divergence').slice(0, 1).map(d => (
<div key={d.id} style={{
padding: '9px 11px', borderRadius: 7, marginBottom: 8,
background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.25)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<span style={{ fontSize: 10, fontWeight: 800, color: '#f59e0b', letterSpacing: '0.05em' }}> DIVERGENCE</span>
<span style={{ fontSize: 10, color: 'var(--ink-4)' }}>
{/* 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
</span>
</div>
<div style={{ fontSize: 12, fontWeight: 600 }}>
@{d.handle} said <span style={{ color: d.post_action === 'bullish' || d.post_action === 'buy' ? 'var(--up)' : 'var(--down)' }}>{d.post_action}</span>
{' '}on <strong>{d.ticker}</strong>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
{/* /1000+"k" alone printed "$2100k" for $2.1M positions */}
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null
? `${d.usd_after >= 1e6 ? '$' + (d.usd_after / 1e6).toFixed(1) + 'M' : '$' + (d.usd_after / 1e3).toFixed(0) + 'K'} on-chain`
: ''}
</div>
</div>
))}
{/* Top digest tickers */}
{kolDigest && kolDigest.tickers.slice(0, 2).map(t => (
<div key={t.ticker} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 0', borderBottom: '1px solid var(--line)',
}}>
<div>
<span style={{ fontSize: 12, fontWeight: 700 }}>{t.ticker}</span>
<span style={{ fontSize: 10, color: 'var(--ink-4)', marginLeft: 6 }}>{t.kol_count} KOLs</span>
</div>
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 4,
background: t.side === 'long' ? 'var(--up-soft)' : t.side === 'short' ? 'rgba(220,38,38,.12)' : 'var(--bg-sunk)',
color: t.side === 'long' ? 'var(--up)' : t.side === 'short' ? 'var(--down)' : 'var(--ink-3)',
}}>
{t.dominant_action.toUpperCase()}
</span>
</div>
))}
</section>
)}
</aside>
</div>
<div className="dash-grid">
{/* Left: Chart + signal stream */}
<div className="stack gap-l">
<div className="card" style={{ padding: 24 }}>
{/* The big price lives in the market hero above — repeating it here
just duplicated the same number 32px tall. The chart's own price
axis label shows the live price; this header instead hosts the
asset/timeframe controls right next to the chart they drive. */}
<div className="row between" style={{ marginBottom: 14, alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div className="tiny">Live chart · {asset} · signal markers</div>
<div className="overview-controls">
<div className="asset-switch">
<button className={asset === 'BTC' ? 'on' : ''} onClick={() => setAsset('BTC')}>
<span className="asset-dot btc" /> BTC
</button>
<button className={asset === 'ETH' ? 'on' : ''} onClick={() => setAsset('ETH')}>
<span className="asset-dot eth" /> ETH
</button>
</div>
<div className="tf-bar">
{(['5m', '15m', '1H', '4H', '1D'] as const).map(t => (
<button key={t} className={timeframe === t ? 'on' : ''} onClick={() => setTimeframe(t)}>{t}</button>
))}
</div>
</div>
</div>
{chartErr && (
<div style={{ padding: '8px 12px', margin: '0 0 8px', fontSize: 12,
color: 'var(--down)', background: 'var(--down-soft, rgba(220,38,38,.08))',
borderRadius: 8 }}>
{asset} {timeframe} chart failed to load {chartErr}{' '}
<button className="btn ghost" style={{ fontSize: 11, padding: '3px 10px', marginLeft: 6 }}
onClick={() => setChartReload(n => n + 1)}>Retry</button>
</div>
)}
<div className="chart-wrap" style={{ position: 'relative' }}>
{/* Loading overlay — shown while candles are fetching (candles=[] and no error).
Prevents the user from seeing a blank lightweight-charts canvas. */}
{!hasPriceData && !chartErr && (
<div style={{
position: 'absolute', inset: 0, zIndex: 4,
background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 10, color: 'var(--ink-3)', fontSize: 13,
}}>
<span className="live-dot" style={{ background: 'var(--amber)' }} />
Loading chart
</div>
)}
<ChartPanel
posts={chartPosts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={(id) => {
setSelectedDayPosts(null)
setSelectedPostId(id)
}}
onSelectDayPosts={(dayPosts) => {
setSelectedDayPosts(dayPosts)
setSelectedPostId(null)
}}
/>
</div>
<div className="chart-footnote">
<div className="item">
Markers
<InfoTip
placement="top"
width={280}
text={asset === 'BTC'
? 'Green dot = buy, red = short; a number is the signal count in that candle. Amber dashed line = macro reversal signal.'
: 'Green dot = buy, red = short; a number is the signal count in that candle.'}
/>
</div>
<div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div>
</div>
</div>
{/* Recent signals list */}
<div>
<div className="section-title">
<h2>Recent signals</h2>
<button
onClick={() => setHideFiltered(v => !v)}
title="Hide hold/filtered (gray) posts and show only buy/short signals"
style={{
padding: '4px 10px', borderRadius: 6,
border: '1px solid var(--line)',
background: hideFiltered ? 'var(--ink)' : 'transparent',
color: hideFiltered ? 'var(--bg)' : 'var(--ink-3)',
fontSize: 11, cursor: 'pointer', fontWeight: 600,
marginLeft: 'auto', marginRight: 8,
}}
>
{hideFiltered ? 'Show all' : 'Signals only'}
</button>
{/* No count hint here — the page-head PageHint already shows
"X actionable · Y tracked" for the whole feed. */}
</div>
<div className="post-stream">
{recentPosts.map(p => (
<div key={p.id} className={p.id === freshPostId ? 'signal-enter' : undefined}>
<PostRow post={p} selected={selectedPostId === p.id} onClick={() => {
// 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)
}} />
</div>
))}
</div>
</div>
</div>
{/* Right rail */}
<div className="rail" ref={railRef}>
{/* Day-view: all posts from clicked day */}
{selectedDayPosts ? (
<div className="card" style={{ padding: 20 }}>
<div className="row between" style={{ marginBottom: 14 }}>
<div>
<div className="tiny">{selectedDayPosts.length} posts · this candle</div>
<div style={{ fontSize: 15, fontWeight: 600, marginTop: 2 }}>
<LocalDateTime iso={selectedDayPosts[0].published_at} opts={{ month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }} />
</div>
</div>
<button className="icon-btn" onClick={() => { setSelectedDayPosts(null); setSelectedPostId(null) }}
style={{ width:26, height:26, borderRadius:'50%' }}>
<svg width="10" height="10" viewBox="0 0 24 24">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</button>
</div>
<div style={{ display:'flex', flexDirection:'column', gap:10 }}>
{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 (
<div key={p.id}
style={{ border:`1px solid ${expanded?'var(--amber)':'var(--line)'}`,
borderRadius:'var(--r-sm)', overflow:'hidden', transition:'border-color 0.15s' }}>
{/* collapsed row — always visible */}
<div onClick={() => setSelectedPostId(expanded ? null : p.id)}
style={{ padding:12, cursor:'pointer',
background: expanded ? 'var(--bg-sunk)' : 'transparent' }}>
<div className="row between" style={{ marginBottom:6 }}>
<div className="row gap-s">
<SignalPill signal={p.signal} />
<span className={`chip ${p.sentiment === 'bullish' ? 'up' : p.sentiment === 'bearish' ? 'down' : 'neutral'}`}
style={{ fontSize:10 }}>{p.sentiment}</span>
</div>
<span style={{ fontSize:11, color:'var(--ink-3)' }}><TimeAgo iso={p.published_at} /></span>
</div>
<p style={{ fontSize:12, lineHeight:1.5, margin:'0 0 8px', color:'var(--ink-2)',
...(expanded ? {} : {
display:'-webkit-box', WebkitLineClamp: 3,
WebkitBoxOrient:'vertical', overflow:'hidden'
}) }}>
{p.text}
</p>
<div style={{ display:'flex', gap:6, alignItems:'center' }}>
<div style={{ flex:1, height:3, background:'var(--line)', borderRadius:2, overflow:'hidden' }}>
<div style={{ width:p.ai_confidence+'%', height:'100%', background:'var(--amber)', borderRadius:2 }} />
</div>
<span style={{ fontSize:10, color:'var(--ink-3)', whiteSpace:'nowrap' }}>{p.ai_confidence}% conf</span>
</div>
</div>
{/* expanded detail */}
{expanded && (
<div style={{ padding:'0 12px 12px', borderTop:'1px solid var(--line)' }}>
{p.ai_reasoning && (
<>
<div className="ai-reasoning-label" style={{ margin:'10px 0 8px' }}>AI reasoning</div>
<div className="ai-reasoning-card" style={{ padding: '14px 15px', fontSize: 14, lineHeight: 1.7 }}>
{p.ai_reasoning}
</div>
</>
)}
{impact && (
<>
<div className="tiny" style={{ margin:'10px 0 6px' }}>Price impact · {impact.asset}</div>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:6 }}>
{(['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 (
<div key={key} style={{ padding:8, background:'var(--bg-sunk)',
borderRadius:'var(--r-sm)', border:'1px solid var(--line)', textAlign:'center' }}>
<div style={{ fontSize:10, color:'var(--ink-3)', marginBottom:3 }}>{label}</div>
<div className={`delta ${(v??0)>=0?'up':'down'}`} style={{ fontSize:13, fontWeight:600 }}>
{fmtImpactPct(v)}
</div>
{correct != null && (
<div style={{ fontSize:10, marginTop:3, color: correct ? 'var(--up)' : 'var(--down)' }}>
{correct ? '✓' : '✗'}
</div>
)}
</div>
)
})}
</div>
</>
)}
</div>
)}
</div>
)
})}
</div>
</div>
) : selectedPost ? (
<PostDetail post={selectedPost} onClose={() => setSelectedPostId(null)} />
) : (
<SelectHint />
)}
</div>
</div>
</div>
)
}