Files
trumpsignal-frontend/app/[locale]/DashboardClient.tsx
T
k 01be8e790b fix: dashboard live price + 24h % + chart tick + null-pnl safety
Five bugs in the same blast radius (places we render a price/percent).

DashboardClient.tsx
  • Hero price read lastCandle.close (REST candle, frozen until user
    changes asset/tf). WebSocket livePrices was set but never read.
    Now: livePrice ?? lastCandle.close.
  • "+21.84% · 4H" was (last_close - first_open) / first_open over
    the entire candle window. limit=200 + tf=4H = 33 days shown as
    "4H". Find candle closest to now-24h, label "24h" consistently.

ChartPanel.tsx
  • Chart only re-rendered on candles prop change. Live WS ticks
    ignored. Added useEffect calling series.update() with
    livePrices[asset] on every tick.

analytics/page.tsx
  • reduce(sum + trade.pnl_usd) when pnl_usd is null → NaN poison.
  • Math.max/min over null cast to 0 → bogus "best/worst trade".
  • Win-rate denominator counted null-pnl trades as losses.
  • calcDrawdownPct had the same null poisoning.
  Fix: gate aggregations on priced subset only.

trades/page.tsx
  • ROI could divide by 0/null; entry/exit crashed on null.toLocaleString().
  Added guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:57:28 +08:00

437 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 } from 'react'
import { useAccount } from 'wagmi'
import type { TrumpPost, BotPerformance, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
import { usePriceSocket } from '@/lib/useRealtimeData'
import { getPrices, getUserPublic } from '@/lib/api'
import ChartPanel from '@/components/dashboard/ChartPanel'
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
interface Props {
initialPosts: TrumpPost[]
initialPerformance?: BotPerformance
}
// ── 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)' }}>@realDonaldTrump</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">AI reasoning</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<br/>or a post below to see details
</p>
</div>
)
}
// ── Main dashboard ─────────────────────────────────────────────────────────────
export default function DashboardClient({ initialPosts, initialPerformance }: Props) {
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
const { address, isConnected } = useAccount()
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
const [candles, setCandles] = useState<Candle[]>([])
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
// For 1D: show all posts from selected day
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
useEffect(() => {
if (!isConnected || !address) {
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
return
}
// Clear account-scoped bot state immediately when the connected wallet
// changes so a previous wallet's status never leaks into the next one.
setSubscribed(false)
setHlApiKeySet(false)
setBotReadiness('unknown')
getUserPublic(address.toLowerCase())
.then((user) => {
setSubscribed(user.active)
setHlApiKeySet(user.hl_api_key_set)
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
})
.catch(() => {})
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
usePriceSocket({
onPrice: (a, price) => setLivePrice(a, price),
onNewPost: (post) => setPosts((prev) => [post as TrumpPost, ...prev].slice(0, 500)),
})
useEffect(() => {
setCandles([])
getPrices(asset, timeframe)
.then(setCandles)
.catch(() => {})
}, [asset, timeframe])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
const recentPosts = posts.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 winRate = initialPerformance?.win_rate ?? 0
const netPnl = initialPerformance?.net_pnl_usd ?? 0
const hasPriceData = candles.length > 0
const hasPerformanceData = Boolean(initialPerformance)
return (
<div className="page wide">
<div className="page-head">
<div>
<h1 className="page-title">Signal monitor</h1>
<p className="page-sub">
{actionablePosts} actionable signals · Auto-trader {botReadiness === 'ready' ? 'ready' : hlApiKeySet ? 'saved' : isSubscribed ? 'subscribed' : 'standby'} · {hasPriceData ? 'Live market context' : 'Waiting for market data'}
</p>
</div>
<div className="row gap-s">
<span className="chip"><span className="live-dot" />Live feed</span>
<span className="chip">{totalPosts} posts tracked</span>
</div>
</div>
{/* KPI Row */}
<div className="kpi-row">
<div className="kpi">
<div className="label"><span className="asset-dot btc" style={{ width: 10, height: 10 }} /> BTC</div>
<div className="value">{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}</div>
<div className="foot">
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'}</span>
<span>24h</span>
</div>
</div>
<div className="kpi">
<div className="label">Signals today</div>
<div className="value">{signalsToday}</div>
<div className="foot"><span>{actionablePosts} actionable total</span></div>
</div>
<div className="kpi accent">
<div className="label">30d Net P&amp;L</div>
<div className="value">{netPnl >= 0 ? '+$' : '-$'}{Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}</div>
<div className="foot"><span>{hasPerformanceData ? 'Bot performance' : 'Performance pending'}</span></div>
</div>
<div className="kpi">
<div className="label">Win rate</div>
<div className="value">{initialPerformance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
<div className="foot"><span>{hasPerformanceData ? `${initialPerformance?.total_trades ?? 0} trades` : 'Waiting for trade history'}</span></div>
</div>
</div>
<div className="dash-grid">
{/* Left: Chart + signal stream */}
<div className="stack gap-l">
<div className="card" style={{ padding: 24 }}>
<div className="row between" style={{ marginBottom: 18, alignItems: 'flex-start' }}>
<div>
<div className="tiny">Price · {asset}</div>
<div className="row gap-m" style={{ marginTop: 6 }}>
<div className="hero-value mono" style={{ fontSize: 32 }}>
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
</div>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
</div>
</div>
<div className="stack gap-s" style={{ alignItems: 'flex-end' }}>
<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>
<div className="chart-wrap">
<ChartPanel
posts={posts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={(id) => {
setSelectedDayPosts(null)
setSelectedPostId(id)
}}
onSelectDayPosts={(dayPosts) => {
setSelectedDayPosts(dayPosts)
setSelectedPostId(null)
}}
/>
</div>
<div className="chart-footnote">
<div className="item"><span className="legend-dot" style={{ background: '#26a69a' }} /> Buy signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#ef5350' }} /> Short signal</div>
<div className="item"><span className="legend-dot" style={{ background: '#aaaaaa' }} /> Hold / filtered</div>
<div style={{ marginLeft: 'auto' }}>Click marker to see details </div>
</div>
</div>
{/* Recent signals list */}
<div>
<div className="section-title">
<h2>Recent signals</h2>
<span className="hint">Showing {recentPosts.length} of {totalPosts}</span>
</div>
<div className="post-stream">
{recentPosts.map(p => (
<PostRow key={p.id} post={p} />
))}
</div>
</div>
</div>
{/* Right rail */}
<div className="rail">
{/* 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>
)
}