ui: tighten dashboard copy and fix layout issues
This commit is contained in:
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user