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
+53 -26
View File
@@ -128,11 +128,14 @@ function MetricCard({
activeIndex,
chartHref,
chartLabel = 'CoinGlass',
hero = false,
}: {
rank: number
label: string
value: string
tone?: Tone
/** What the indicator IS, one sentence. Do NOT enumerate threshold bands
* here — the chips below each card already list them. */
hint: string
summary: string
thresholds: Array<{ label: string; tone?: Tone }>
@@ -143,31 +146,54 @@ function MetricCard({
* the dual "Data source + Chart" pattern confused users about which to click. */
chartHref: string
chartLabel?: string
/** Horizontal headline layout for a single full-width card (Valuation).
* The stacked layout stretched across the panel left a dead middle. */
hero?: boolean
}) {
const head = (
<div className="macro-metric-head">
<div className="macro-metric-title">
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
<span>{label}</span>
<InfoTip text={hint} placement="top" width={280} />
</div>
<div className="macro-metric-value">{value}</div>
</div>
)
const chips = (
<div className="macro-thresholds">
{thresholds.map((item, i) => (
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
{item.label}
</ThresholdChip>
))}
</div>
)
const action = (
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
)
if (hero) {
return (
<article className={`macro-metric-card tone-${tone} hero`}>
{head}
<div className="macro-hero-body">
<div className="macro-summary">{summary}</div>
{chips}
</div>
{action}
</article>
)
}
return (
<article className={`macro-metric-card tone-${tone}`}>
<div className="macro-metric-head">
<div className="macro-metric-title">
<span className="macro-rank">{String(rank).padStart(2, '0')}</span>
<span>{label}</span>
<InfoTip text={hint} placement="top" width={280} />
</div>
<div className="macro-metric-value">{value}</div>
</div>
{head}
<div className="macro-summary">{summary}</div>
<div className="macro-thresholds">
{thresholds.map((item, i) => (
<ThresholdChip key={item.label} tone={item.tone} active={i === activeIndex}>
{item.label}
</ThresholdChip>
))}
</div>
<div className="macro-actions">
<SourceButton href={chartHref}>{chartLabel}</SourceButton>
</div>
{chips}
{action}
</article>
)
}
@@ -402,11 +428,12 @@ export default function MacroPanel() {
<Section title="Valuation">
<div className="macro-grid one">
<MetricCard
hero
rank={1}
label="AHR999"
value={ind.ahr999?.toFixed(4) ?? '—'}
tone={toneAhr(ind.ahr999)}
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. < 0.45 = buy/accumulation zone, 0.450.75 = fair value leaning cheap, 0.751.2 = neutral / no edge, > 1.2 = expensive."
hint="BTC valuation index: price vs 200-day geometric mean × price vs fitted long-run growth curve. Lower = cheaper."
summary={ahrSay}
activeIndex={ahrActive}
thresholds={[
@@ -457,7 +484,7 @@ export default function MacroPanel() {
label="Altcoin Season"
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
tone={toneAltseason(ind.altcoin_season_index)}
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). < 25 = Bitcoin season, 2560 = mixed, 6075 = alt strength building, ≥ 75 = altseason."
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). Higher = broader alt strength."
summary={altSay}
activeIndex={altActive}
thresholds={[
@@ -474,7 +501,7 @@ export default function MacroPanel() {
label="BTC Dominance"
value={fmtPct(ind.btc_dominance_pct)}
tone={toneBtcDominance(ind.btc_dominance_pct)}
hint="BTC's share of total crypto market cap. < 45% = alt rotation, 4555% = balanced, 5565% = BTC-led / defensive, > 65% = flight to safety."
hint="BTC's share of total crypto market cap. High dominance = defensive, capital hiding in BTC; low = alt rotation."
summary={domSay}
activeIndex={domActive}
thresholds={[
@@ -491,7 +518,7 @@ export default function MacroPanel() {
label="ETH / BTC"
value={ind.eth_btc_ratio == null ? '—' : ind.eth_btc_ratio.toFixed(5)}
tone={toneEthBtc(ind.eth_btc_ratio)}
hint="Relative strength of ETH vs BTC. < 0.025 = defensive / BTC-led, 0.0250.04 = range, > 0.04 = ETH and alts gaining leadership."
hint="Relative strength of ETH vs BTC — a proxy for broader crypto risk appetite."
summary={ebrSay}
activeIndex={ebrActive}
thresholds={[
@@ -547,7 +574,7 @@ export default function MacroPanel() {
label="ETF Net Flow (1d)"
value={fmtSignedUsd(ind.etf_flow_net_usd_1d)}
tone={toneEtfFlow(ind.etf_flow_net_usd_1d)}
hint="Yesterday's total net flow into/out of US spot BTC ETFs. > +$200M = strong bid, $50M to +$50M = noise, < $200M = heavy outflow."
hint="Yesterday's total net flow into/out of US spot BTC ETFs — a direct read on institutional demand."
summary={etfSay}
activeIndex={etfActive}
thresholds={[
+50 -8
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { TrumpPost, Candle } from '@/types'
import { useDashboardStore } from '@/store/dashboard'
@@ -33,6 +33,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
// subscribeVisibleTimeRangeChange callback (when the user pans / zooms).
const repositionMacroRef = useRef<(() => void) | null>(null)
const fittedRef = useRef(false)
// Chart creation is async (dynamic import). If the candles fetch wins the
// race (common — SWR serves them instantly from cache), the data effect
// runs while seriesRef is still null and bails; nothing re-triggers it
// afterwards, so the chart stays EMPTY and the 2s live-tick effect then
// paints a single lone candle via series.update(). This state flips when
// the chart actually exists so the data effect re-runs.
const [chartReady, setChartReady] = useState(false)
// The live-tick effect must never run before setData() — updating an empty
// series creates a one-candle chart.
const dataLoadedRef = useRef(false)
const postsRef = useRef(posts)
postsRef.current = posts
const selectedPostIdRef = useRef(externalSelectedId)
@@ -111,7 +121,11 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 360,
// Follow the container, don't hardcode: .chart-wrap is 360px on
// desktop but 300/240px behind !important media queries. A fixed
// 360 overflows those and overflow:hidden cuts off the bottom of
// the chart — lowest candles, markers, and the whole time axis.
height: containerRef.current.clientHeight || 360,
layout: {
background: { color: colors.background },
textColor: colors.textColor,
@@ -145,6 +159,16 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
seriesRef.current = series
// Breathing room above/below the candles. Markers render ~10px outside
// the bar's high/low; with the default tight bottom margin a crash
// candle at the bottom of the autoscaled range gets its marker dots
// clipped by the pane edge.
series.priceScale().applyOptions({
scaleMargins: { top: 0.12, bottom: 0.16 },
})
setChartReady(true)
// Re-project macro signal time → pixel X on every visible-range
// change (pan / zoom / data update / resize). Without this the line
// is set ONCE in the data effect and then stays stuck at a fixed
@@ -226,7 +250,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
ro = new ResizeObserver(() => {
if (containerRef.current && !destroyed) {
chart.applyOptions({ width: containerRef.current.clientWidth })
chart.applyOptions({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight || 360,
})
// Width change → new pixel coordinates → re-project the macro
// marker too, otherwise it'd drift relative to the candles on
// window resize / sidebar toggle.
@@ -269,6 +296,8 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
ro?.disconnect()
themeObserver?.disconnect()
fittedRef.current = false
dataLoadedRef.current = false
setChartReady(false)
if (chartRef.current) {
// @ts-expect-error lightweight-charts type
chartRef.current.remove()
@@ -291,7 +320,10 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
useEffect(() => {
const series = seriesRef.current
const chart = chartRef.current
if (!series || !chart || candles.length === 0) return
if (!series || !chart || candles.length === 0) {
dataLoadedRef.current = false
return
}
const sorted = [...candles].sort((a, b) => a.time - b.time)
@@ -303,6 +335,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
low: c.low,
close: c.close,
})))
dataLoadedRef.current = true
const minTime = sorted[0].time
const maxTime = sorted[sorted.length - 1].time
@@ -313,6 +346,13 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
return t >= minTime && t <= maxTime
})
// Markers show actionable signals only. Hold/filtered posts vastly
// outnumber signals and their gray dots pile into unreadable clusters;
// they stay browsable in the Recent-signals list instead.
const markerPosts = visible.filter(
(p) => p.signal === 'buy' || p.signal === 'short' || p.signal === 'sell',
)
const bucketByTf: Record<string, number> = {
'5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400, '1w': 604800,
}
@@ -320,7 +360,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
const bucketSecs = bucketByTf[timeframeRef.current.toLowerCase()] ?? candleSpacing
const bucketMap = new Map<number, typeof visible>()
for (const p of visible) {
for (const p of markerPosts) {
const pt = Math.floor(new Date(p.published_at).getTime() / 1000)
const bucket = Math.floor(pt / bucketSecs) * bucketSecs
if (!bucketMap.has(bucket)) bucketMap.set(bucket, [])
@@ -415,7 +455,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
chart.timeScale().fitContent()
fittedRef.current = true
}
}, [candles, posts, externalSelectedId, asset])
}, [candles, posts, externalSelectedId, asset, chartReady])
useEffect(() => { fittedRef.current = false }, [timeframe, asset])
@@ -426,7 +466,9 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
// the live tick exceeds them.
useEffect(() => {
const series = seriesRef.current as any
if (!series) return
// dataLoadedRef: update() on a series that hasn't had setData() yet
// creates a chart with a single lone candle.
if (!series || !dataLoadedRef.current) return
const live = livePrices[asset]
if (live == null || !candles.length) return
const last = candles[candles.length - 1]
@@ -442,7 +484,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
return (
<div
ref={containerRef}
style={{ width: '100%', height: 360, borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
style={{ width: '100%', height: '100%', borderRadius: 'var(--r-sm)', overflow: 'hidden', cursor: 'crosshair', position: 'relative' }}
/>
)
}
+13 -20
View File
@@ -149,7 +149,6 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
if (!mounted) return null
const settingsHref = `/${locale}/settings#bot-config`
const settingsLabel = system === 'trump' ? 'Trump Signal' : 'Macro Vibes'
// Macro Vibes (System 2) is MANAGE-ONLY by design — it NEVER auto-opens a
// trade. bot_engine.process_post early-returns for sys2 sources, so the
@@ -184,7 +183,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
opacity: 0.8,
}}
>
{settingsLabel} settings
Settings
</Link>
</div>
)
@@ -211,8 +210,10 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
width: 6, height: 6, borderRadius: '50%',
background: 'var(--ink-4)', flexShrink: 0,
}} />
{/* No "{s.name}" prefix — the page title right above already names
the system; repeating it here read "Trump Signal" twice per screen. */}
<span style={{ color: 'var(--ink-2)', fontWeight: 600 }}>
{s.name} Auto-Trade
Auto-Trade
</span>
<span>·</span>
<span style={{ color: 'var(--ink-4)' }}>
@@ -236,10 +237,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
boxShadow: 'var(--shadow-1)',
}}
>
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
Settings
</span>
<span>{settingsLabel}</span>
<span>Settings</span>
<span aria-hidden="true"></span>
</Link>
</div>
@@ -297,22 +295,20 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
{/* Header */}
<div style={{ padding: '14px 16px', background: s.soft,
borderLeft: `4px solid ${s.accent}` }}>
{/* Header is just "Auto-Trade" — the page title already names the
system, and the switch row below repeats "Auto-Trade" otherwise. */}
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
{s.name} {isZh ? '控制面板' : 'Auto-Trade'}
{isZh ? '控制面板' : 'Auto-Trade'}
</div>
</div>
{/* Master Auto-Trade switch */}
<div style={{ padding: '16px' }}>
<div style={ROW}>
<div>
<div style={{ fontSize: 14, fontWeight: 700 }}>
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 事件交易' : '· event-driven'}</span>
</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3 }}>
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
</div>
{/* The card header above already says "Auto-Trade" — this row only
carries the ON/OFF legend, not a second title. */}
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Pill active={autoOn} onClick={() => flipAuto(true)} tone="green">ON</Pill>
@@ -377,10 +373,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
boxShadow: 'var(--shadow-1)',
}}
>
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
Settings
</span>
<span>{settingsLabel}</span>
<span>Settings</span>
<span aria-hidden="true"></span>
</Link>
</div>
+3 -3
View File
@@ -835,7 +835,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Bot locks in profit when the position gains this much. Required.</span>
<span className="hint">Bot locks in profit when the position gains this much.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -851,7 +851,7 @@ export default function BotConfigPanel() {
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Bot cuts the loss when the position falls this much. Required.</span>
<span className="hint">Bot cuts the loss when the position falls this much.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -998,7 +998,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Daily trading cap
<span className="hint">Daily spending cap — bot stops opening new trades once it hits this amount.</span>
<span className="hint">Bot stops opening new trades once it has spent this amount in a day.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
+18 -14
View File
@@ -288,14 +288,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
<table className="table">
<thead>
<tr>
{/* P&L right after Side — it's the column this page exists for.
As the last of 8 columns it sat off-screen on phones (the
table scrolls inside its card) until the user swiped past
entry/exit/hold/trigger to find it. */}
<th>{isZh ? '来源' : 'Source'}</th>
<th>{isZh ? '资产' : 'Asset'}</th>
<th>{isZh ? '方向' : 'Side'}</th>
<th>P&amp;L</th>
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
<th>P&amp;L</th>
</tr>
</thead>
<tbody>
@@ -346,20 +350,8 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
{t.side === 'long' ? (isZh ? '↗ 做多' : '↗ LONG') : (isZh ? '↘ 做空' : '↘ SHORT')}
</span>
</td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
<td>
<div className="stack" style={{ alignItems: 'flex-end' }}>
<div className="stack" style={{ alignItems: 'flex-start' }}>
{t.pnl_usd === null || t.pnl_usd === undefined ? (
<span
style={{ fontSize: 12, color: 'var(--ink-4)' }}
@@ -379,6 +371,18 @@ export default function TradeTable({ trades, loading, locked = false }: Props) {
)}
</div>
</td>
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
)}
</td>
</tr>
)
})}
+30 -4
View File
@@ -13,10 +13,12 @@
* If you need a paragraph, you're explaining the wrong thing.
* * The icon is intentionally subtle (12 px circle, ink-4 colour). It
* should disappear visually until the user actively scans for help.
* * The tooltip uses CSS hover only — no JS positioning, no portals.
* Trade-off: it's clipped by the nearest `overflow:hidden` ancestor.
* Pages that need tooltips inside a `<table>` cell should add
* `overflow: visible` to that row/td.
* * The tooltip uses CSS hover positioning no portals. The only JS is
* an on-reveal measurement that clamps the bubble inside the viewport
* (a centred bubble on an icon near the screen edge used to hang
* off-screen, worst on phones). Trade-off: it's still clipped by the
* nearest `overflow:hidden` ancestor. Pages that need tooltips inside
* a `<table>` cell should add `overflow: visible` to that row/td.
*
* Usage:
* <span>Latest cycle <InfoTip text="The single most recent funding payment, in %." /></span>
@@ -27,6 +29,7 @@
* />
*/
import { useRef } from 'react'
import type { CSSProperties } from 'react'
type Placement = 'top' | 'bottom' | 'left' | 'right'
@@ -46,8 +49,28 @@ export default function InfoTip({
width = 240,
iconStyle,
}: Props) {
const rootRef = useRef<HTMLSpanElement>(null)
// Clamp the bubble inside the viewport when it's revealed. The bubble is
// CSS-centred on the icon, so near a screen edge it overflowed off-screen.
// Measured at shift=0, then the needed horizontal offset is written to the
// --tip-shift var consumed by the .infotip-top/-bottom transforms.
function clampIntoViewport() {
const bubble = rootRef.current?.querySelector<HTMLElement>('.infotip-bubble')
if (!bubble) return
bubble.style.setProperty('--tip-shift', '0px')
const r = bubble.getBoundingClientRect()
const vw = document.documentElement.clientWidth
const margin = 8
let shift = 0
if (r.left < margin) shift = margin - r.left
else if (r.right > vw - margin) shift = (vw - margin) - r.right
if (shift !== 0) bubble.style.setProperty('--tip-shift', `${shift}px`)
}
return (
<span
ref={rootRef}
className={`infotip infotip-${placement}`}
// role=button is a slight overstatement but it lets screen-reader users
// know there's a thing to tab to. The actual content is in data-tip and
@@ -56,6 +79,9 @@ export default function InfoTip({
tabIndex={0}
aria-label={text}
style={{ ['--tip-w' as string]: `${width}px`, ...iconStyle }}
onMouseEnter={clampIntoViewport}
onFocus={clampIntoViewport}
onTouchStart={clampIntoViewport}
>
<span aria-hidden="true" className="infotip-icon">?</span>
<span className="infotip-bubble" role="tooltip">{text}</span>