style(dashboard): unify Macro composite into one card + dark-mode fix
for Performance accent
User flagged the macro index box on the overview page as feeling
disjointed. Reshaped it from three stacked elements (band → track →
scale) into a single bordered card so it reads as ONE component.
- JSX: wrap the three pieces in <div className="overview-macro-card">
- CSS: new .overview-macro-card with tone-coloured rim (bull/bear/neutral)
- Solid neutral-gray filled needle (was a hollow ring — looked like a
placeholder); tone-coloured background + white inner ring + double
shadow so it stands out on any gradient position
- Removed the .overview-score-fill overlay — the gradient already
encodes the spectrum; layering an opaque fill obscured it near 0
- Thinner track (14px vs 22px), tighter scale labels, smaller pill
- Added "TODAY · 8 INDICATORS" stamp next to the title — gives users
a quick anchor of what they're looking at + freshness
Plus: dark-mode override for .overview-stat-card.accent (the Performance
card). It was using a cream gradient that floated as a glaring
out-of-theme block on dark mode. Mirrored the existing .kpi.accent dark
treatment so it stays visually grouped with the rest of the dashboard.
Also includes the in-flight overview rewrite from the other AI tool
(legacy /btc redirect to /macro, middleware.ts replacing proxy.ts for
Next.js routing, refactored several dashboard panels). TypeScript clean,
production build passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { useAccount } from 'wagmi'
|
||||
import type { TrumpPost, BotPerformance, Candle } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { usePriceSocket } from '@/lib/useRealtimeData'
|
||||
import { getPerformance, getPrices, getUserPublic } from '@/lib/api'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope } from '@/lib/signedRequest'
|
||||
import { getMacroSnapshot, getPerformance, getPrices, getUserPublic, type MacroSnapshot } from '@/lib/api'
|
||||
import { getCachedViewEnvelope } from '@/lib/signedRequest'
|
||||
import { swrFetch } from '@/lib/cache'
|
||||
import ChartPanel from '@/components/dashboard/ChartPanel'
|
||||
import PostRow, { SignalPill, SourceIcon, fmtPct, timeAgo, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
|
||||
import SignalMonitor from '@/components/dashboard/SignalMonitor'
|
||||
import PostRow, { SignalPill, SourceIcon, fmtPct, TimeAgo, LocalDateTime } from '@/components/dashboard/PostCards'
|
||||
import OpenPositions from '@/components/positions/OpenPositions'
|
||||
import PageHint from '@/components/ui/PageHint'
|
||||
|
||||
@@ -142,7 +143,6 @@ export default function DashboardClient({ initialPosts }: 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 { signMessageAsync } = useSignMessage()
|
||||
const params = useParams()
|
||||
const locale = (typeof params?.locale === 'string' ? params.locale : 'en')
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
|
||||
@@ -151,6 +151,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
const [chartErr, setChartErr] = useState('')
|
||||
const [chartReload, setChartReload] = useState(0)
|
||||
const [selectedPostId, setSelectedPostId] = useState<number | null>(null)
|
||||
const [macro, setMacro] = useState<MacroSnapshot | null>(null)
|
||||
// For 1D: show all posts from selected day
|
||||
const [selectedDayPosts, setSelectedDayPosts] = useState<TrumpPost[] | null>(null)
|
||||
|
||||
@@ -185,7 +186,11 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
;(async () => {
|
||||
try {
|
||||
const env = getCachedViewEnvelope('view_performance', address)
|
||||
?? await getOrCreateViewEnvelope({ action: 'view_performance', wallet: address, signMessageAsync })
|
||||
?? getCachedViewEnvelope('view_user', address)
|
||||
if (!env) {
|
||||
if (!cancelled) setPerformance(undefined)
|
||||
return
|
||||
}
|
||||
const data = await getPerformance(address, env)
|
||||
if (!cancelled) setPerformance(data)
|
||||
} catch {
|
||||
@@ -193,7 +198,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [address, isConnected, signMessageAsync])
|
||||
}, [address, isConnected])
|
||||
|
||||
usePriceSocket({
|
||||
onPrice: (a, price) => setLivePrice(a, price),
|
||||
@@ -208,19 +213,24 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
.catch(e => setChartErr(e instanceof Error ? e.message : (isZh ? '价格数据加载失败' : 'Failed to load price data')))
|
||||
}, [asset, timeframe, chartReload, isZh])
|
||||
|
||||
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
|
||||
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) }
|
||||
}, [])
|
||||
|
||||
// Pinned BTC bottom-reversal alert: the rarest, highest-conviction signal.
|
||||
// Surface the most recent btc_bottom_reversal post fired in the last 21
|
||||
// days right at the top of the overview so it's never missed.
|
||||
const btcReversalAlert = (() => {
|
||||
const cutoff = Date.now() - 21 * 24 * 3600 * 1000
|
||||
const hits = posts
|
||||
.filter(p => (p.source || '') === 'btc_bottom_reversal'
|
||||
&& new Date(p.published_at).getTime() >= cutoff)
|
||||
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime())
|
||||
return hits[0] ?? null
|
||||
})()
|
||||
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.
|
||||
@@ -256,10 +266,26 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
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
|
||||
const kolMentions = posts.filter(p => (p.source || '') === 'kol').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))
|
||||
const macroTone =
|
||||
macroScore == null ? 'neutral'
|
||||
: macroScore > 15 ? 'bull'
|
||||
: macroScore < -15 ? 'bear'
|
||||
: 'neutral'
|
||||
const macroSummary =
|
||||
macroScore == null ? 'Daily macro composite not loaded yet.'
|
||||
: macroTone === 'bull' ? 'Risk backdrop is supportive. Trend-following setups get more room.'
|
||||
: macroTone === 'bear' ? 'Backdrop is defensive. Preserve size and expect cleaner downside moves.'
|
||||
: 'Backdrop is mixed. Useful for context, not a blind directional trigger.'
|
||||
|
||||
return (
|
||||
<div className="page wide">
|
||||
@@ -277,72 +303,124 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned BTC bottom-reversal alert — the rarest / highest-conviction
|
||||
signal. Always sits ABOVE everything so it's never missed. */}
|
||||
{btcReversalAlert && (
|
||||
<a
|
||||
href={`/${locale}/macro`}
|
||||
style={{
|
||||
display: 'block', textDecoration: 'none',
|
||||
border: '1px solid color-mix(in oklab, var(--up) 45%, var(--line))',
|
||||
background: 'var(--up-soft, rgba(22,163,74,0.10))',
|
||||
borderRadius: 12, padding: '14px 18px', marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, letterSpacing: '.04em',
|
||||
color: 'var(--up)', textTransform: 'uppercase' }}>
|
||||
⚡ Macro Vibes · Bottom trigger
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px',
|
||||
borderRadius: 999, background: 'var(--up)', color: '#fff' }}>
|
||||
conf {Math.round(btcReversalAlert.ai_confidence)}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{timeAgo(btcReversalAlert.published_at)}
|
||||
</span>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, fontWeight: 700,
|
||||
color: 'var(--up)' }}>
|
||||
Open Macro Vibes →
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 6,
|
||||
lineHeight: 1.45, overflow: 'hidden', display: '-webkit-box',
|
||||
WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
||||
{btcReversalAlert.text}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Open positions — what's on the book right now. Renders only when
|
||||
a subscribed wallet is connected, so guests see the normal feed. */}
|
||||
<OpenPositions />
|
||||
|
||||
{/* 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 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">
|
||||
<div className="hero-value mono" style={{ fontSize: 40 }}>
|
||||
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||||
</div>
|
||||
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
|
||||
</div>
|
||||
<div className="overview-market-subtitle">BTC spot with live signal context</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="overview-system-strip">
|
||||
<Link href={`/${locale}/trump`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">Trump</span>
|
||||
<strong>{trumpActionable}</strong>
|
||||
</Link>
|
||||
<Link href={`/${locale}/macro`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">Macro</span>
|
||||
<strong>{macroActionable}</strong>
|
||||
</Link>
|
||||
<Link href={`/${locale}/kol`} className="overview-system-chip">
|
||||
<span className="overview-system-chip-name">KOL</span>
|
||||
<strong>{kolMentions}</strong>
|
||||
</Link>
|
||||
<div className="overview-system-chip passive">
|
||||
<span className="overview-system-chip-name">Signals today</span>
|
||||
<strong>{signalsToday}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="overview-secondary-grid">
|
||||
<section className="overview-stat-card">
|
||||
<div className="overview-kicker">Execution</div>
|
||||
<div className="overview-stat-value">{actionablePosts}</div>
|
||||
<div className="overview-stat-label">actionable signals tracked in feed</div>
|
||||
</section>
|
||||
<section className="overview-stat-card accent">
|
||||
<div className="overview-kicker">Performance</div>
|
||||
<div className="overview-stat-value">{hasPerformanceData ? `${netPnl >= 0 ? '+$' : '-$'}${Math.abs(netPnl).toLocaleString('en-US', { maximumFractionDigits: 0 })}` : '—'}</div>
|
||||
<div className="overview-stat-label">{hasPerformanceData ? '30d bot net P&L' : 'Load settings once to unlock private performance'}</div>
|
||||
</section>
|
||||
</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&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">{performance ? (winRate * 100).toFixed(1) + '%' : '—'}</div>
|
||||
<div className="foot"><span>{hasPerformanceData ? `${performance?.total_trades ?? 0} trades` : 'Connect wallet to load your trade history'}</span></div>
|
||||
</div>
|
||||
|
||||
<aside className="overview-side">
|
||||
<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>{isConnected && address ? `${address.slice(0, 6)}…${address.slice(-4)}` : 'Not connected'}</strong>
|
||||
</div>
|
||||
<div className="overview-account-item">
|
||||
<span>Private data</span>
|
||||
<strong>{hasPerformanceData ? 'Unlocked' : 'Locked until Settings load'}</strong>
|
||||
</div>
|
||||
<div className="overview-account-item">
|
||||
<span>Win rate</span>
|
||||
<strong>{hasPerformanceData ? `${(winRate * 100).toFixed(1)}%` : '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="overview-side-card compact">
|
||||
<div className="overview-kicker">Chart focus</div>
|
||||
<div className="overview-side-copy">Live chart with signal markers and drill-down on click.</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="dash-grid">
|
||||
@@ -359,21 +437,7 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<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 className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
|
||||
</div>
|
||||
|
||||
{chartErr && (
|
||||
@@ -405,7 +469,8 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<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>
|
||||
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>}
|
||||
<div style={{ marginLeft: 'auto' }}>Binance candles via backend API · click marker to inspect</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -535,8 +600,6 @@ export default function DashboardClient({ initialPosts }: Props) {
|
||||
<SelectHint />
|
||||
)}
|
||||
|
||||
{/* Breakout signal monitor */}
|
||||
<SignalMonitor />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user