ui: tighten dashboard copy and fix layout issues

This commit is contained in:
k
2026-06-14 21:58:56 +08:00
parent 4c3c8c6f87
commit 8534d90589
19 changed files with 1036 additions and 345 deletions
+215 -61
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useParams } from 'next/navigation'
@@ -9,13 +9,14 @@ 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 { 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.
@@ -159,17 +160,42 @@ export default function DashboardClient({ initialPosts }: Props) {
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.
@@ -199,7 +225,7 @@ export default function DashboardClient({ initialPosts }: Props) {
})
.catch(() => {})
return () => { cancelled = true }
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness])
}, [address, isConnected, setSubscribed, setHlApiKeySet, setBotReadiness, setPaperMode])
useEffect(() => {
if (!isConnected || !address) {
@@ -255,9 +281,63 @@ export default function DashboardClient({ initialPosts }: Props) {
.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])
// 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() {
@@ -275,6 +355,50 @@ export default function DashboardClient({ initialPosts }: Props) {
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
@@ -290,12 +414,21 @@ export default function DashboardClient({ initialPosts }: Props) {
return () => { alive = false }
}, [])
const selectedPost = posts.find(p => p.id === selectedPostId) ?? null
// 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.
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)
// 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]
@@ -322,12 +455,14 @@ export default function DashboardClient({ initialPosts }: Props) {
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
: 0
const totalPosts = posts.length
// 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 = 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 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
@@ -343,10 +478,9 @@ export default function DashboardClient({ initialPosts }: Props) {
// 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 === 'BULL' || macroRegime === 'BULLISH' ? 'bull'
: macroRegime === 'BEAR' || macroRegime === 'BEARISH' ? 'bear'
: 'neutral'
: '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.'
@@ -362,7 +496,14 @@ export default function DashboardClient({ initialPosts }: Props) {
Trump · Macro · KOL divergence live.
</PageHint>
</div>
<span className="chip"><span className="live-dot" />Live</span>
{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
@@ -387,21 +528,6 @@ export default function DashboardClient({ initialPosts }: Props) {
</div>
<div className="overview-market-subtitle">{asset} · 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}`}>
@@ -488,17 +614,17 @@ export default function DashboardClient({ initialPosts }: Props) {
<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' },
{ 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 }) => (
<div key={head} style={{ display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 12 }}>
{ 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}</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>
</div>
</Link>
))}
</section>
)}
@@ -532,7 +658,10 @@ export default function DashboardClient({ initialPosts }: Props) {
{' '}on <strong>{d.ticker}</strong>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
Then {d.onchain_action.replace('_', ' ')}{d.usd_after != null ? `$${(d.usd_after / 1000).toFixed(0)}k on-chain` : ''}
{/* /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>
))}
@@ -565,20 +694,27 @@ export default function DashboardClient({ initialPosts }: Props) {
{/* 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 }}>
<AnimatedNumber
className="hero-value mono"
style={{ fontSize: 32 }}
value={displayPrice}
display={displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
/>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
{/* 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 className="tiny" style={{ color: 'var(--ink-3)' }}>Live chart with signal markers</div>
</div>
{chartErr && (
@@ -605,7 +741,7 @@ export default function DashboardClient({ initialPosts }: Props) {
</div>
)}
<ChartPanel
posts={posts}
posts={chartPosts}
candles={candles}
externalSelectedId={selectedPostId}
onSelectPost={(id) => {
@@ -620,10 +756,16 @@ export default function DashboardClient({ initialPosts }: Props) {
</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>
{asset === 'BTC' && <div className="item"><span className="legend-dot macro" /> Macro reversal highlight</div>}
<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>
@@ -632,10 +774,22 @@ export default function DashboardClient({ initialPosts }: Props) {
<div>
<div className="section-title">
<h2>Recent signals</h2>
<span className="hint">
{actionable.length > 0 ? `${actionable.length} actionable · ` : ''}
{`${totalPosts} tracked`}
</span>
<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 => (
@@ -655,7 +809,7 @@ export default function DashboardClient({ initialPosts }: Props) {
</div>
{/* Right rail */}
<div className="rail">
<div className="rail" ref={railRef}>
{/* Day-view: all posts from clicked day */}
{selectedDayPosts ? (
<div className="card" style={{ padding: 20 }}>
+71 -45
View File
@@ -238,43 +238,59 @@ export default function AnalyticsPageClient() {
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>{accuracy.total_directional_signals} directional signals measured</span>
<span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 600, marginLeft: 'auto' }}>Public · no login needed</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}>
{/* Overall */}
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>Overall</div>
{(['m5','m15','m1h'] as const).map(w => {
const d = accuracy.overall[w]
const pct = d.accuracy_pct
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
return (
<div key={w} className="row between" style={{ marginBottom: 3 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct != null ? `${pct.toFixed(0)}%` : '—'}</span>
</div>
)
})}
</div>
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
return (
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '12px 14px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label} <span style={{ fontWeight: 400 }}>({data.count})</span></div>
{(['m5','m15','m1h'] as const).map(w => {
const d = data[w]
if (d.checked < 2) return <div key={w} className="row between" style={{ marginBottom: 3 }}><span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span><span style={{ fontSize: 12, color: 'var(--ink-3)' }}></span></div>
const pct = d.accuracy_pct
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
return (
<div key={w} className="row between" style={{ marginBottom: 3 }}>
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>{w.replace('m1','1').replace('m','')}</span>
<span style={{ fontSize: 13, fontWeight: 700, color, fontVariantNumeric: 'tabular-nums' }}>{pct.toFixed(0)}%</span>
{(() => {
// One full-width block per measurement window. Big overall number
// + a hit-rate bar (50% tick = coin-flip baseline) + per-direction
// breakdown. `repeat(3, 1fr)` stretches across the card so no
// whitespace is stranded on wide screens.
const WINDOWS = ['m5', 'm15', 'm1h'] as const
const WINDOW_LABEL = { m5: 'After 5 min', m15: 'After 15 min', m1h: 'After 1 hour' } as const
const pctColor = (pct: number) => pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
const fmtBreakdown = (pct: number | null, sparse: boolean) =>
sparse || pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%`
// auto-fit, not repeat(3,1fr) — a hard 3-col grid overflowed the
// viewport on phones and forced the whole page to scroll sideways.
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 12 }}>
{WINDOWS.map(w => {
const pct = accuracy.overall[w].accuracy_pct
const has = pct != null && !Number.isNaN(pct)
return (
<div key={w} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', textTransform: 'uppercase', letterSpacing: '0.06em', display: 'flex', alignItems: 'center' }}>
{WINDOW_LABEL[w]}
<InfoTip text={`Share of signals where price had moved in the signalled direction ${w === 'm5' ? '5 minutes' : w === 'm15' ? '15 minutes' : '1 hour'} after the post. 50% = coin flip.`} placement="top" />
</div>
)
})}
</div>
)
})}
</div>
<div className="mono" style={{ fontSize: 30, fontWeight: 600, letterSpacing: '-0.01em', marginTop: 8, color: has ? pctColor(pct) : 'var(--ink-4)', fontVariantNumeric: 'tabular-nums' }}>
{has ? `${pct.toFixed(0)}%` : '—'}
</div>
{/* hit-rate bar with a 50% coin-flip tick */}
<div style={{ position: 'relative', height: 6, background: 'var(--surface-3)', borderRadius: 999, marginTop: 10, overflow: 'hidden' }}>
{has && (
<div style={{ position: 'absolute', inset: 0, width: `${Math.min(100, Math.max(0, pct))}%`, background: pctColor(pct), borderRadius: 999, opacity: 0.75 }} />
)}
<div style={{ position: 'absolute', left: '50%', top: 0, bottom: 0, width: 1.5, background: 'var(--ink-4)', opacity: 0.55 }} />
</div>
<div style={{ display: 'flex', gap: 16, marginTop: 12, fontSize: 12, color: 'var(--ink-3)', flexWrap: 'wrap' }}>
{Object.entries(accuracy.by_signal).map(([sig, d]) => {
const sparse = d[w].checked < 2
const v = fmtBreakdown(d[w].accuracy_pct, sparse)
const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell'
return (
<span key={sig} style={{ whiteSpace: 'nowrap' }}>
{label}{' '}
<strong style={{ color: sparse ? 'var(--ink-4)' : 'var(--ink-2)', fontVariantNumeric: 'tabular-nums' }}>{v}</strong>
<span style={{ color: 'var(--ink-4)' }}> ({d.count})</span>
</span>
)
})}
</div>
</div>
)
})}
</div>
)
})()}
</div>
)}
@@ -315,10 +331,21 @@ export default function AnalyticsPageClient() {
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
</div>
<div className="row gap-s" style={{ marginTop: 8 }}>
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
<span className="chip">{isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}</span>
</div>
{/* Win-rate chip only when there are trades to rate — a red
"0.0% win rate" next to an em-dash P&L misreads as a losing
record when the wallet simply has no data. */}
{pricedTrades.length > 0 && (
<div className="row gap-s" style={{ marginTop: 8 }}>
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
</div>
)}
{filteredTrades.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 12, lineHeight: 1.6, maxWidth: 520 }}>
No closed bot trades in this window. P&amp;L, win rate and the
per-trade metrics below fill in once auto-trade executions close
or pick a longer window on the top right.
</div>
)}
</div>
</div>
</div>
@@ -339,15 +366,14 @@ export default function AnalyticsPageClient() {
{/* Signal accuracy is now shown at the top of the page (public, no login needed).
Removed duplicate block here to avoid showing the same data twice. */}
{!filteredTrades.length && (
{/* Empty-state card renders only for states no other element explains:
- "window empty but trades exist" → Performance hero's inline note
- privateLocked → the unlock card at the top of the page */}
{!filteredTrades.length && !privateLocked && (!isConnected || !address || !trades.length) && (
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
<p style={{ fontSize: 14 }}>
{!isConnected || !address
? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.')
: privateLocked
? (isZh ? '签名解锁后可查看你的真实业绩和交易历史。' : 'Sign once above to unlock your personal P&L and trade history.')
: trades.length
? (isZh ? `${period} 时间窗内还没有已平仓交易。` : `No trades closed in the ${period} window yet.`)
: (isZh ? '还没有交易数据。机器人开始执行后,这里会自动出现统计。' : 'No trade data yet. The bot will populate analytics once it starts executing.')}
</p>
</div>
+3 -4
View File
@@ -79,9 +79,6 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli
const archiveTotalPages = Math.max(1, Math.ceil(totalPosts / ARCHIVE_PAGE_SIZE))
const archiveSafePage = Math.min(archivePage, archiveTotalPages)
const allSourcesCount = sourceCounts.reduce((sum, item) => sum + item.count, 0)
const selectedCount = src === 'all'
? totalPosts
: sourceCounts.find(s => s.source === src)?.count ?? totalPosts
const sourceTabs: [string, number][] = [
['all', allSourcesCount],
...sourceCounts.map(item => [item.source, item.count] as [string, number]),
@@ -92,7 +89,9 @@ export default function ArchivePageClient({ initialData = null }: ArchivePageCli
<div className="page-head">
<div>
<h1 className="page-title">Archive</h1>
<PageHint count={`${selectedCount} legacy posts`}>
{/* No count slot — the source tabs and pagination already show
per-source and total counts. */}
<PageHint>
Signals from retired scanner experiments
(rsi_reversal, sma_reclaim, breakout, test/phase1). Read-only
the bot no longer acts on any of these.
+13 -2
View File
@@ -16,8 +16,19 @@ export default async function ArchivePage() {
const initialData: PostListResponse | null = await getInitialPostPage(30, 1, {
filters: { archiveOnly: true },
legacyFallback: async () => {
const legacyItems = await getPosts(500, 1).catch(() => null)
return legacyItems ? buildArchiveFallbackResponse(legacyItems, 1, 30) : null
// Old backend (no /posts-paged archive_only): /posts caps at 500 per
// page, so a single page only sees the latest 500 posts globally — older
// archive rows get buried under fresh truth posts and silently dropped.
// Page through a few windows so archive coverage isn't truncated.
const MAX_PAGES = 4 // up to 2000 most-recent posts scanned for archive rows
const collected: Awaited<ReturnType<typeof getPosts>> = []
for (let p = 1; p <= MAX_PAGES; p++) {
const batch = await getPosts(500, p).catch(() => null)
if (!batch || batch.length === 0) break
collected.push(...batch)
if (batch.length < 500) break // last page reached
}
return collected.length ? buildArchiveFallbackResponse(collected, 1, 30) : null
},
})
+52 -6
View File
@@ -723,6 +723,34 @@ html[data-theme="dark"] .infotip-bubble {
min-height: 66px;
}
/* Hero variant — for a section with a SINGLE headline card (Valuation/AHR999).
The stacked card layout stretched full-width left a huge dead middle; the
hero lays out horizontally: value block left, summary + chips filling the
middle, chart button right. Wraps back to stacked on narrow screens. */
.macro-metric-card.hero {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: clamp(16px, 3vw, 40px);
min-height: 0;
}
.macro-metric-card.hero .macro-hero-body {
flex: 1 1 300px;
min-width: 0;
}
.macro-metric-card.hero .macro-summary {
margin-top: 0;
min-height: 0;
}
.macro-metric-card.hero .macro-thresholds {
margin-top: 10px;
}
.macro-metric-card.hero .macro-actions {
margin-top: 0;
padding-top: 0;
flex-shrink: 0;
}
.macro-thresholds {
display: flex;
flex-wrap: wrap;
@@ -1066,22 +1094,24 @@ html[data-theme="dark"] .macro-threshold-chip.neutral.active {
opacity: 1;
transform: translateY(0);
}
/* Placement variants (default = top) */
/* Placement variants (default = top).
--tip-shift is set by InfoTip.tsx on reveal: it nudges a bubble that would
hang past the viewport edge back on-screen (icons near the screen margin). */
.infotip-top .infotip-bubble {
bottom: calc(100% + 8px);
left: 50%; transform: translate(-50%, 2px);
left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), 2px);
}
.infotip-top:hover .infotip-bubble,
.infotip-top:focus-visible .infotip-bubble {
transform: translate(-50%, 0);
transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
}
.infotip-bottom .infotip-bubble {
top: calc(100% + 8px);
left: 50%; transform: translate(-50%, -2px);
left: 50%; transform: translate(calc(-50% + var(--tip-shift, 0px)), -2px);
}
.infotip-bottom:hover .infotip-bubble,
.infotip-bottom:focus-visible .infotip-bubble {
transform: translate(-50%, 0);
transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
}
.infotip-left .infotip-bubble {
right: calc(100% + 8px);
@@ -1562,6 +1592,10 @@ html[data-theme="dark"] .chip.down { color: oklch(80% 0.16 27); }
gap: 20px;
align-items: start;
}
/* Grid items default to min-width:auto, so an intrinsically-wide child (the
lightweight-charts canvas, a long URL) blows the column past the viewport
on phones — the whole page then scrolls sideways. Let columns shrink. */
.dash-grid > * { min-width: 0; }
.hero-value {
font-size: 44px;
@@ -2308,9 +2342,13 @@ html[data-theme="dark"] .post-row.signal-short {
.src-ico.whale { background: oklch(94% 0.08 150); color: oklch(40% 0.15 150); font-size: 13px; }
.src-ico.manual { background: oklch(94% 0.05 60); color: oklch(45% 0.15 60); font-size: 13px; }
.src-ico.external { background: var(--bg-sunk); color: var(--ink-2); }
/* min-width:0 — without it a long unbreakable URL in the text widens the 1fr
grid column past the card and the right edge (meta tag included) is clipped. */
.post-body { min-width: 0; }
.post-body .meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
font-size: 12px;
@@ -2321,6 +2359,7 @@ html[data-theme="dark"] .post-row.signal-short {
line-height: 1.5;
color: var(--ink);
margin: 0 0 10px;
overflow-wrap: anywhere;
}
.post-aside {
display: flex;
@@ -2812,8 +2851,15 @@ html[data-theme="dark"] .post-row.signal-short {
CSS-hover-only tooltips require). */
.infotip:active .infotip-bubble {
opacity: 1;
transform: none;
}
/* Keep the viewport-clamp shift on tap — `transform: none` here used to
discard it and re-centre the bubble off-screen. */
.infotip-top:active .infotip-bubble,
.infotip-bottom:active .infotip-bubble {
transform: translate(calc(-50% + var(--tip-shift, 0px)), 0);
}
.infotip-left:active .infotip-bubble { transform: translate(0, -50%); }
.infotip-right:active .infotip-bubble { transform: translate(0, -50%); }
.macro-action-label {
font-size: 12px;
+12 -12
View File
@@ -230,7 +230,8 @@ function DigestTickerChip({
border: active ? `2px solid ${sideColor}` : `1px solid ${sideColor}55`,
boxShadow: active ? `0 10px 24px ${sideColor}22` : 'none',
cursor: 'pointer', textAlign: 'left',
minHeight: 108,
// No fixed minHeight — content is 3 short lines; 108px left the bottom
// ~40% of every chip empty (worst on phones, 2 chips per row).
width: '100%',
transform: active ? 'translateY(-2px)' : 'none',
opacity: hasActive && !active ? 0.6 : 1,
@@ -479,15 +480,12 @@ function WalletCheckWidget({
{row.verdict.label}
</span>
</div>
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600, marginBottom: 4 }}>
{/* Speakers + conviction dropped — the digest chip for the same
ticker directly above already shows the handles and max
conviction %. This column keeps only the one-line talk summary. */}
<div style={{ fontSize: 13, color: 'var(--ink)', fontWeight: 600 }}>
{row.talkLine}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{row.speakers}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>
{row.conviction}
</div>
</div>
<div style={{ minWidth: 0 }}>
@@ -540,9 +538,9 @@ function WalletCheckWidget({
}
function TickerChips({ tickers, isZh }: { tickers: KolTicker[]; isZh: boolean }) {
if (!tickers.length) {
return <span style={{ color: 'var(--ink-3)', fontSize: 12 }}></span>
}
// No tickers → render nothing. A lone "—" dangled under every card whose
// post had no extracted assets (most of the feed).
if (!tickers.length) return null
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{tickers.map((t, i) => (
@@ -811,7 +809,9 @@ export default function KolPage({
<div className="page-head">
<div>
<h1 className="page-title">{'KOL Signals'}</h1>
<PageHint count={`${serverTotal} posts`}>
{/* No count slot — the digest meta line ("X posts · Y assets") and
the feed pagination already show the totals. */}
<PageHint>
Arthur Hayes, Delphi, Bankless, and 22 more their public calls vs what their wallets actually do.
</PageHint>
</div>
+17 -95
View File
@@ -9,7 +9,6 @@ import { swrFetch } from '@/lib/cache'
import PostRow from '@/components/dashboard/PostCards'
import SystemControl from '@/components/signals/SystemControl'
import InfoTip from '@/components/ui/InfoTip'
import SignalMonitor from '@/components/dashboard/SignalMonitor'
// MacroPanel is 631 lines with heavy indicator math — split it out.
const MacroPanel = dynamic(() => import('@/components/btc/MacroPanel'), {
@@ -84,18 +83,9 @@ export default function MacroVibesPage({
<div className="page">
<div className="page-head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
background: 'var(--bg-sunk)',
color: 'var(--ink-3)', border: '1px solid var(--line)',
letterSpacing: '0.04em',
}}>
{isZh ? '手动开仓 · Bot 托管' : 'You open · bot manages'}
</span>
</div>
{/* No "You open · bot manages" badge — the SystemControl strip right
below says the same thing verbatim ("You open · bot manages exit"). */}
<h1 className="page-title" style={{ margin: 0 }}>{isZh ? '宏观氛围' : 'Macro Vibes'}</h1>
</div>
<span className="chip"><span className="live-dot" />Live</span>
</div>
@@ -133,14 +123,17 @@ export default function MacroVibesPage({
users are reasoning about the broader risk regime. The Funding tab
has its own live funding panel below. */}
{tab === 'bottom' && <MacroPanel />}
{/* SignalMonitor (ETH/LINK Breakout Monitor) unmounted 2026-06-12: the
backend scanner is disabled (services/funding_signal.py _enabled=False,
operator-only toggle), so the panel only ever showed "Paused / No
signals yet" — and breakout scanning is unrelated to funding reversal
anyway. Component kept at components/dashboard/SignalMonitor.tsx;
remount here if the scanner is ever re-enabled. */}
{tab === 'funding' && (
<>
<FundingPanel
isZh={isZh}
initialSnapshot={initialFundingSnapshot}
/>
<SignalMonitor />
</>
<FundingPanel
isZh={isZh}
initialSnapshot={initialFundingSnapshot}
/>
)}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', margin: '16px 0 12px' }}>
@@ -317,14 +310,10 @@ function FundingPanel({
)}
</div>
{snap.history && snap.history.length > 1 && (
<FundingSparkline
history={snap.history}
cadenceHours={snap.cadence_hours ?? 8}
extremeCumPct={thr}
isZh={isZh}
/>
)}
{/* 7-day sparkline removed: the y-range was forced to include the ±threshold
lines, so in normal regimes the curve rendered as a flat line on the zero
axis with ~80% of the plot being empty danger-zone shading. The stat boxes
above (latest / 24h avg / 30d cumulative / signal state) carry the signal. */}
</div>
)
}
@@ -352,73 +341,6 @@ function StatBox({ label, value, color, sub, hint }: {
)
}
// Tiny inline SVG sparkline of the last 7 days of funding cycles.
// `extremeCumPct` is the 30d cumulative threshold (e.g. 3.0). To project it as
// a per-cycle reference line we divide by the number of cycles in 30 days at
// the venue's cadence (24h × 30d / cadence_h). This is the per-cycle rate that
// — sustained for 30 days — would hit the extreme bucket.
function FundingSparkline({ history, cadenceHours, extremeCumPct, isZh }: {
history: { t: number; rate_pct: number }[]
cadenceHours: number
extremeCumPct: number
isZh: boolean
}) {
const W = 600, H = 90, PAD = 6
const cyclesIn30d = Math.max(1, (30 * 24) / Math.max(cadenceHours, 0.5))
const perCycleThr = extremeCumPct / cyclesIn30d // e.g. 3% / 90 ≈ 0.033%
const rates = history.map(h => h.rate_pct)
// Ensure threshold lines are always visible in the y-range
const minR = Math.min(...rates, -perCycleThr * 1.2)
const maxR = Math.max(...rates, perCycleThr * 1.2)
const span = maxR - minR || 1
const x = (i: number) => PAD + (i / (history.length - 1)) * (W - 2 * PAD)
const y = (r: number) => PAD + (1 - (r - minR) / span) * (H - 2 * PAD)
const zeroY = y(0)
const posThrY = y(perCycleThr)
const negThrY = y(-perCycleThr)
const path = history.map((h, i) => `${i === 0 ? 'M' : 'L'} ${x(i)} ${y(h.rate_pct)}`).join(' ')
const last = history[history.length - 1]
// Color the dot by whether the latest cycle is inside the danger band
const inDanger = Math.abs(last.rate_pct) >= perCycleThr
const dotColor = inDanger
? (last.rate_pct > 0 ? 'var(--down)' : 'var(--up)')
: 'var(--amber, #f59e0b)'
return (
<div>
<div style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 10, color: 'var(--ink-4)', marginBottom: 4,
}}>
<span>{isZh ? '7 日资金费率(单周期 %' : '7-day funding (per-cycle %)'}</span>
<span>{isZh ? `危险区间:每周期 ±${perCycleThr.toFixed(3)}%` : `danger band: ±${perCycleThr.toFixed(3)}% / cycle`}</span>
</div>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: H, display: 'block' }}>
{/* danger zones — shaded above +thr and below -thr */}
<rect x={PAD} y={PAD} width={W - 2 * PAD} height={Math.max(0, posThrY - PAD)}
fill="var(--down)" opacity={0.07} />
<rect x={PAD} y={negThrY} width={W - 2 * PAD} height={Math.max(0, H - PAD - negThrY)}
fill="var(--up)" opacity={0.07} />
{/* zero line */}
<line x1={PAD} x2={W - PAD} y1={zeroY} y2={zeroY}
stroke="var(--line-2, var(--line))" strokeWidth={1} />
{/* positive / negative threshold lines */}
<line x1={PAD} x2={W - PAD} y1={posThrY} y2={posThrY}
stroke="var(--down)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
<line x1={PAD} x2={W - PAD} y1={negThrY} y2={negThrY}
stroke="var(--up)" strokeWidth={1} strokeDasharray="3 3" opacity={0.6} />
{/* funding curve */}
<path d={path} fill="none" stroke="var(--amber, #f59e0b)" strokeWidth={1.5} />
{/* current value dot */}
<circle cx={x(history.length - 1)} cy={y(last.rate_pct)} r={3.5} fill={dotColor} />
</svg>
</div>
)
}
function BtcSkeleton() {
return (
<div className="post-stream" style={{ marginTop: 8 }}>
+17 -1
View File
@@ -151,12 +151,26 @@ export default function TradesPageClient() {
<div className="page-head">
<div>
<h1 className="page-title">{isZh ? '交易执行' : 'Trades'}</h1>
{/* Says what the page is, not where things sit — the section cards
below are self-labelled ("Open positions", KPI row, table). */}
<PageHint>
Open positions above · closed trade history with realized P&amp;L below.
What the bot holds right now, and every trade it has closed.
</PageHint>
</div>
</div>
{/* Guest view: a zero-filled KPI row + empty table reads like a broken
dashboard. Show one connect prompt instead and skip the data UI. */}
{mounted && !isConnected ? (
<div className="card" style={{ padding: '32px 28px', textAlign: 'center' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to see your trades</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.6 }}>
Positions and trade history are wallet-bound and private.
Use the Connect wallet button in the top-right corner.
</div>
</div>
) : (
<>
{needsSetup && (
<div className="card" style={{
padding: '14px 18px', marginBottom: 16,
@@ -210,6 +224,8 @@ export default function TradesPageClient() {
)}
<TradeTable trades={trades} loading={loading} locked={needsUnlock} />
</>
)}
</div>
)
}
+6 -18
View File
@@ -170,24 +170,12 @@ export default function TrumpSignalPage({ initialData = null }: TrumpSignalPageP
<div className="page">
<div className="page-head">
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 6, fontSize: 11, fontWeight: 700,
background: 'color-mix(in oklab, var(--up) 12%, transparent)',
color: 'var(--up)', border: '1px solid color-mix(in oklab, var(--up) 25%, transparent)',
letterSpacing: '0.04em',
}}>
Auto-trade
</span>
</div>
<PageHint count={
sigFilter === 'buy' ? `${counts.buy} buy signals`
: sigFilter === 'short' ? `${counts.short} short signals`
: sigFilter === 'actionable' ? `${counts.actionable} actionable signals`
: `${counts.actionable} actionable / ${counts.all} posts`
}>
{/* No "⚡ Auto-trade" badge — the SystemControl strip right below
always shows the Auto-Trade feature + its live state. */}
<h1 className="page-title" style={{ margin: 0 }}>Trump Signal</h1>
{/* No count slot — the filter tabs right below already show the
per-filter numbers (All / Actionable / Buy / Short). */}
<PageHint>
Every Truth Social post scored in &lt;3s. Trades only when conviction clears the threshold.
</PageHint>
</div>