Horizon redesign: space theme, visual dashboard, chart reskin
Replaces the citron/green glass theme with the Horizon direction (mockup at /design-horizon): space-void background, starfield, dim Mars horizon, rim-cyan accent, rose for negative. Palette validated for CVD separation and contrast on the dark surface. - tailwind.config: gray -> cool space neutrals, blue/emerald -> cyan scale, red -> rose; display font Space Grotesk - globals.css: Horizon tokens, atmosphere, denser glass, cyan buttons/inputs, price-rail / R-bar / radar chart CSS - Dashboard rebuilt: verdict hero, top-pick card with spatial price rail, KPI tiles, open positions as diverging R-bars with expandable detail + trade chart (OHLCV since entry, entry/stop/target levels), radar list with per-setup disqualify reason, watchlist chips - OpenTradesPanel: table replaced by R-bar rows; all fields kept in the drill-down (shares, P&L, alpha, trailing stop, sell) - qualification: disqualifyReason() mirrors qualifiesSetup rule order - CandlestickChart: canvas colors moved to Horizon palette; S/R now cyan/neutral so rose stays reserved for the stop level - ScoreCard: radar score fingerprint with hover values - Design mockup pages included (routed in App.tsx) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,10 +10,12 @@ import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Section } from '../components/ui/Section';
|
||||
import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
|
||||
import { PriceRail } from '../components/charts/horizon';
|
||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { formatPrice } from '../lib/format';
|
||||
import { tradePnl } from '../lib/paperTrade';
|
||||
import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification';
|
||||
import {
|
||||
qualifiesSetup, disqualifyReason, activationSummary, primaryTargetProbability,
|
||||
} from '../lib/qualification';
|
||||
import type { TradeSetup } from '../lib/types';
|
||||
|
||||
function fmtR(value: number | null): string {
|
||||
@@ -23,8 +25,8 @@ function fmtR(value: number | null): string {
|
||||
|
||||
function rColor(value: number | null): string {
|
||||
if (value === null) return 'text-gray-400';
|
||||
if (value > 0) return 'text-emerald-400';
|
||||
if (value < 0) return 'text-red-400';
|
||||
if (value > 0) return 'text-emerald-300';
|
||||
if (value < 0) return 'text-red-300';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ function Metric({ label, value, sub, valueClass = 'text-gray-100' }: {
|
||||
return (
|
||||
<div className="glass glass-hover p-5">
|
||||
<p className="section-index">{label}</p>
|
||||
<p className={`num mt-2 text-3xl font-semibold ${valueClass}`}>{value}</p>
|
||||
<p className={`font-display mt-2 text-3xl font-semibold ${valueClass}`}>{value}</p>
|
||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
@@ -52,7 +54,7 @@ function DirectionTag({ direction }: { direction: string }) {
|
||||
const isLong = direction === 'long';
|
||||
return (
|
||||
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
||||
isLong ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
|
||||
isLong ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/15 text-red-300'
|
||||
}`}>
|
||||
{direction}
|
||||
</span>
|
||||
@@ -66,10 +68,90 @@ function convictionLabel(action: TradeSetup['recommended_action']): string {
|
||||
return '—';
|
||||
}
|
||||
|
||||
function convictionClass(action: TradeSetup['recommended_action']): string {
|
||||
if (action?.endsWith('_HIGH')) return 'text-emerald-300';
|
||||
if (action?.endsWith('_MODERATE')) return 'text-amber-300';
|
||||
return 'text-gray-400';
|
||||
/** The one focal card: today's top qualified setup as a spatial price rail. */
|
||||
function FocusCard({ setup, name, gateNote }: {
|
||||
setup: TradeSetup;
|
||||
name: string | undefined;
|
||||
gateNote: string;
|
||||
}) {
|
||||
const prob = primaryTargetProbability(setup);
|
||||
return (
|
||||
<section className="glass p-6 sm:p-7" aria-label="Top qualified setup">
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="num mt-2.5 whitespace-nowrap rounded-full border border-[#ff6a45]/40 px-2.5 py-1 text-[9.5px] font-semibold uppercase tracking-[0.2em] text-[#ff6a45]">
|
||||
top pick
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display text-4xl font-bold tracking-tight text-gray-100 sm:text-5xl">
|
||||
{setup.symbol}
|
||||
</h2>
|
||||
{name && <span className="text-sm text-gray-400">{name}</span>}
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<DirectionTag direction={setup.direction} />
|
||||
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
|
||||
conviction {convictionLabel(setup.recommended_action)}
|
||||
</span>
|
||||
{setup.momentum_percentile != null && (
|
||||
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
|
||||
residual momentum {Math.round(setup.momentum_percentile)}th %ile
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-10 text-right">
|
||||
<div>
|
||||
<p className="section-index">reward / risk</p>
|
||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||
{setup.rr_ratio.toFixed(1)}
|
||||
<span className="text-lg text-gray-400"> : 1</span>
|
||||
</p>
|
||||
</div>
|
||||
{prob != null && (
|
||||
<div>
|
||||
<p className="section-index">target probability</p>
|
||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||
{Math.round(prob)}
|
||||
<span className="text-lg text-gray-400">%</span>
|
||||
</p>
|
||||
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
|
||||
<span className="block h-full rounded-full bg-blue-500" style={{ width: `${Math.round(prob)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PriceRail
|
||||
direction={setup.direction}
|
||||
entry={setup.entry_price}
|
||||
stop={setup.stop_loss}
|
||||
target={setup.target}
|
||||
current={setup.current_price}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
|
||||
<span className="text-xs text-gray-500">cleared the gate · {gateNote}</span>
|
||||
<span className="flex gap-2.5">
|
||||
<Link
|
||||
to={`/ticker/${setup.symbol}`}
|
||||
className="rounded-lg border border-blue-500/35 bg-blue-500/15 px-4 py-1.5 text-[13px] font-semibold text-blue-300 transition-colors hover:bg-blue-500/25"
|
||||
>
|
||||
Ticker details
|
||||
</Link>
|
||||
<Link
|
||||
to="/signals"
|
||||
className="rounded-lg border border-white/[0.09] px-4 py-1.5 text-[13px] font-semibold text-gray-400 transition-colors hover:bg-white/[0.04] hover:text-gray-200"
|
||||
>
|
||||
All setups
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -101,11 +183,28 @@ export default function DashboardPage() {
|
||||
.slice(0, 5);
|
||||
}, [qualifiedSetups]);
|
||||
|
||||
// Radar: every live setup ranked by the same score, with the gate verdict.
|
||||
const radar = useMemo(() => {
|
||||
if (!activation.data) return [];
|
||||
return [...(trades.data ?? [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.strategy_rank ?? b.momentum_percentile ?? -Infinity) -
|
||||
(a.strategy_rank ?? a.momentum_percentile ?? -Infinity),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((setup, i) => ({
|
||||
setup,
|
||||
rank: i + 1,
|
||||
reason: disqualifyReason(setup, activation.data!),
|
||||
}));
|
||||
}, [trades.data, activation.data]);
|
||||
|
||||
const topWatchlist = useMemo(
|
||||
() =>
|
||||
[...(watchlist.data ?? [])]
|
||||
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
|
||||
.slice(0, 10),
|
||||
.slice(0, 12),
|
||||
[watchlist.data],
|
||||
);
|
||||
|
||||
@@ -131,14 +230,37 @@ export default function DashboardPage() {
|
||||
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers, alphaUsd, alphaPriced };
|
||||
}, [openTrades.data]);
|
||||
|
||||
const liveCount = trades.data?.length ?? 0;
|
||||
const qualifiedCount = qualifiedSetups.length;
|
||||
const verdict = !trades.data || !activation.data
|
||||
? 'Market overview'
|
||||
: qualifiedCount === 0
|
||||
? 'No setup clears the gate today.'
|
||||
: qualifiedCount === 1
|
||||
? 'One setup clears the gate today.'
|
||||
: `${qualifiedCount} setups clear the gate today.`;
|
||||
|
||||
const topPick = topSetups[0];
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-slide-up">
|
||||
{/* Hero */}
|
||||
{/* Hero — the verdict */}
|
||||
<div>
|
||||
<p className="num text-xs uppercase tracking-[0.22em] text-gray-500">{today}</p>
|
||||
<h1 className="font-display mt-1 text-4xl font-bold tracking-tight text-gray-100">
|
||||
Market overview
|
||||
<h1 className="font-display mt-1 text-4xl font-semibold tracking-tight text-gray-100">
|
||||
{verdict}
|
||||
</h1>
|
||||
{trades.data && (
|
||||
<p className="mt-2 text-[15px] text-gray-400">
|
||||
{qualifiedCount} qualified of {liveCount} live setups · {exposure.count} open position{exposure.count === 1 ? '' : 's'}
|
||||
{exposure.rPriced > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
<b className={rColor(exposure.unrealR)}>{fmtR(exposure.unrealR)}</b> unrealized
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Market regime banner */}
|
||||
@@ -158,28 +280,34 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focal setup */}
|
||||
{(trades.isLoading || activation.isLoading) && <SkeletonCard />}
|
||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||
{trades.data && activation.data && (
|
||||
topPick ? (
|
||||
<FocusCard
|
||||
setup={topPick}
|
||||
name={tickerNames.get(topPick.symbol.toUpperCase())}
|
||||
gateNote={activationSummary(activation.data)}
|
||||
/>
|
||||
) : (
|
||||
<Callout variant="empty">
|
||||
No qualified actionable setups right now — the radar below shows what's close and why it doesn't qualify.
|
||||
</Callout>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Metric strip */}
|
||||
{(trades.isLoading || openTrades.isLoading) ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Metric
|
||||
label="Live Setups"
|
||||
value={String(trades.data?.length ?? 0)}
|
||||
sub="latest per ticker & direction"
|
||||
/>
|
||||
<Metric
|
||||
label="Qualified"
|
||||
value={String(qualifiedSetups.length)}
|
||||
sub={activation.data ? activationSummary(activation.data) : 'clears the activation gate'}
|
||||
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric
|
||||
label="Open Risk"
|
||||
value={exposure.count > 0 ? `$${exposure.riskUsd.toFixed(0)}` : '—'}
|
||||
sub={exposure.count > 0 ? `${exposure.count} open · risk to stops` : 'no open trades'}
|
||||
sub={exposure.count > 0 ? `${exposure.count} open · distance to stops` : 'no open trades'}
|
||||
/>
|
||||
<Metric
|
||||
label="Unrealized"
|
||||
@@ -196,10 +324,16 @@ export default function DashboardPage() {
|
||||
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
|
||||
valueClass={
|
||||
exposure.alphaPriced > 0
|
||||
? exposure.alphaUsd >= 0 ? 'text-emerald-400' : 'text-red-400'
|
||||
? exposure.alphaUsd >= 0 ? 'text-emerald-300' : 'text-red-300'
|
||||
: 'text-gray-100'
|
||||
}
|
||||
sub={exposure.alphaPriced > 0 ? `${exposure.alphaPriced} open · vs buy-and-hold SPY` : 'vs buy-and-hold SPY'}
|
||||
sub="open trades vs buy-and-hold SPY"
|
||||
/>
|
||||
<Metric
|
||||
label="Gate"
|
||||
value={trades.data ? `${qualifiedCount} / ${liveCount}` : '—'}
|
||||
valueClass={qualifiedCount > 0 ? 'text-blue-300' : 'text-gray-100'}
|
||||
sub={activation.data ? activationSummary(activation.data) : 'setups clearing the gate'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -207,142 +341,107 @@ export default function DashboardPage() {
|
||||
{/* Open paper trades */}
|
||||
<OpenTradesPanel />
|
||||
|
||||
<div className="grid gap-8 xl:grid-cols-5">
|
||||
{/* Top setups */}
|
||||
<div className="xl:col-span-3">
|
||||
<Section
|
||||
title="Top Setups"
|
||||
hint="current qualified signals"
|
||||
>
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={7} />}
|
||||
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
|
||||
{trades.data && topSetups.length === 0 && (
|
||||
<Callout variant="empty">No qualified actionable setups right now.</Callout>
|
||||
)}
|
||||
{topSetups.length > 0 && (
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-3">Ticker</th>
|
||||
<th className="px-4 py-3">Dir</th>
|
||||
<th className="px-4 py-3 text-right">Current / Target</th>
|
||||
<th className="px-4 py-3 text-right">R:R</th>
|
||||
<th className="px-4 py-3 text-right">Target Prob</th>
|
||||
<th className="px-4 py-3 text-right">Residual</th>
|
||||
<th className="hidden px-4 py-3 md:table-cell">Conviction</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topSetups.map((setup, i) => {
|
||||
const isTopPick = i === 0;
|
||||
const displayedPrice = setup.current_price ?? setup.entry_price;
|
||||
return (
|
||||
<tr
|
||||
key={setup.id}
|
||||
className={`border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03] ${
|
||||
isTopPick ? 'bg-blue-500/[0.06]' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/ticker/${setup.symbol}`} className="font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
{setup.symbol}
|
||||
</Link>
|
||||
{isTopPick && (
|
||||
<span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-300">
|
||||
Top pick
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tickerNames.get(setup.symbol.toUpperCase()) && (
|
||||
<div className="max-w-[160px] truncate text-[11px] text-gray-500">
|
||||
{tickerNames.get(setup.symbol.toUpperCase())}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
|
||||
<td className="num px-4 py-3 text-right">
|
||||
<div className="text-gray-200">{formatPrice(displayedPrice)}</div>
|
||||
<div className="text-[11px] text-gray-500">target {formatPrice(setup.target)}</div>
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">{setup.rr_ratio.toFixed(1)}:1</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-200">
|
||||
{(() => {
|
||||
const p = primaryTargetProbability(setup);
|
||||
return p != null ? `${Math.round(p)}%` : '—';
|
||||
})()}
|
||||
</td>
|
||||
<td className="num px-4 py-3 text-right text-gray-400">
|
||||
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
|
||||
</td>
|
||||
<td className={`hidden px-4 py-3 text-xs font-semibold md:table-cell ${convictionClass(setup.recommended_action)}`}>
|
||||
{convictionLabel(setup.recommended_action)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex justify-end border-t border-white/[0.04] px-4 py-2.5">
|
||||
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
All setups →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* My watchlist */}
|
||||
<div className="xl:col-span-2">
|
||||
<Section title="My Watchlist" hint="today's move">
|
||||
{watchlist.isLoading && <SkeletonTable rows={6} cols={3} />}
|
||||
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
|
||||
{watchlist.data && topWatchlist.length === 0 && (
|
||||
<Callout variant="empty">
|
||||
Your watchlist is empty — open any ticker and tap “☆ Add to watchlist”.
|
||||
</Callout>
|
||||
)}
|
||||
{topWatchlist.length > 0 && (
|
||||
<div className="glass overflow-hidden">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{topWatchlist.map((entry) => (
|
||||
<li key={entry.symbol}>
|
||||
{/* Radar — every live setup, ranked, with the gate verdict */}
|
||||
<Section title="Radar" hint="ranked by strategy score · why each does or doesn't qualify">
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={6} />}
|
||||
{trades.data && radar.length === 0 && (
|
||||
<Callout variant="empty">No live setups right now.</Callout>
|
||||
)}
|
||||
{radar.length > 0 && (
|
||||
<div className="glass px-4 py-1">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{radar.map(({ setup, rank, reason }) => {
|
||||
const qualified = reason === null;
|
||||
const prob = primaryTargetProbability(setup);
|
||||
return (
|
||||
<li
|
||||
key={setup.id}
|
||||
className={`grid grid-cols-[20px_130px_1fr_100px] items-center gap-3 px-2 py-2.5 sm:grid-cols-[20px_150px_46px_1fr_54px_46px_minmax(150px,1fr)] ${
|
||||
qualified ? '' : 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
<span className="num text-[11px] text-gray-500">{rank}</span>
|
||||
<span>
|
||||
<Link
|
||||
to={`/ticker/${entry.symbol}`}
|
||||
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
|
||||
to={`/ticker/${setup.symbol}`}
|
||||
className="block font-medium text-blue-300 transition-colors hover:text-blue-200"
|
||||
>
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-gray-200">{entry.symbol}</span>
|
||||
{entry.composite_score != null && (
|
||||
<span className="num text-[10px] text-gray-500">score {entry.composite_score.toFixed(0)}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-baseline gap-3">
|
||||
<span className="num text-sm text-gray-200">
|
||||
{entry.last_close != null ? formatPrice(entry.last_close) : '—'}
|
||||
</span>
|
||||
<span className={`num w-16 text-right text-sm font-semibold ${rColor(entry.change_pct)}`}>
|
||||
{entry.change_pct != null
|
||||
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
|
||||
: '—'}
|
||||
</span>
|
||||
</span>
|
||||
{setup.symbol}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-white/[0.04] px-4 py-2.5">
|
||||
<Link to="/market" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
|
||||
Full watchlist →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
{tickerNames.get(setup.symbol.toUpperCase()) && (
|
||||
<span className="block max-w-[130px] truncate text-[10.5px] text-gray-500">
|
||||
{tickerNames.get(setup.symbol.toUpperCase())}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden sm:block"><DirectionTag direction={setup.direction} /></span>
|
||||
<span className="hidden items-center gap-2 sm:flex">
|
||||
<span className="h-1 flex-1 overflow-hidden rounded-full bg-white/[0.07]">
|
||||
<span
|
||||
className="block h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.round(setup.momentum_percentile ?? 0)}%`,
|
||||
background: qualified ? 'var(--up)' : 'var(--ink-3)',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="num text-[10px] text-gray-500">
|
||||
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="num hidden text-right text-xs text-gray-400 sm:block">
|
||||
{setup.rr_ratio.toFixed(1)}:1
|
||||
</span>
|
||||
<span className="num hidden text-right text-xs text-gray-400 sm:block">
|
||||
{prob != null ? `${Math.round(prob)}%` : '—'}
|
||||
</span>
|
||||
<span className={`text-right text-[11.5px] ${qualified ? 'text-blue-300' : 'text-gray-500'}`}>
|
||||
{qualified ? '✓ clears the gate' : reason}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex justify-end border-t border-white/[0.04] px-2 py-2.5">
|
||||
<Link to="/signals" className="text-xs font-medium text-blue-300 transition-colors hover:text-blue-200">
|
||||
All setups →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Watchlist — compact chips, drill into any ticker */}
|
||||
<Section title="My Watchlist" hint="today's move">
|
||||
{watchlist.isLoading && <SkeletonTable rows={2} cols={4} />}
|
||||
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
|
||||
{watchlist.data && topWatchlist.length === 0 && (
|
||||
<Callout variant="empty">
|
||||
Your watchlist is empty — open any ticker and tap “☆ Add to watchlist”.
|
||||
</Callout>
|
||||
)}
|
||||
{topWatchlist.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{topWatchlist.map((entry) => (
|
||||
<Link
|
||||
key={entry.symbol}
|
||||
to={`/ticker/${entry.symbol}`}
|
||||
className="inline-flex items-baseline gap-2 rounded-full border border-white/[0.09] px-3.5 py-1.5 text-[13px] transition-colors hover:border-blue-400/40 hover:bg-white/[0.03]"
|
||||
>
|
||||
<b className="font-semibold text-gray-200">{entry.symbol}</b>
|
||||
<span className={`num text-[11.5px] ${rColor(entry.change_pct)}`}>
|
||||
{entry.change_pct != null
|
||||
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
|
||||
: '—'}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
<Link to="/market" className="ml-1 text-xs font-medium text-blue-300 transition-colors hover:text-blue-200">
|
||||
Full watchlist →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user