Match Horizon mockup structure; restore qualified-first radar
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m8s
Deploy / deploy (push) Successful in 35s

Fixes from first prod review of the redesign:

- Top navigation bar (TopBar) replaces the sidebar, like the mockup:
  wordmark + sections + ticker search + jobs/status/logout; content in
  a centered max-width column. MobileNav unchanged.
- Radar panel: qualified setups lead again (the actionable list, as
  the old Top Setups did); non-qualified sit behind an "N below the
  gate" toggle with per-rule reasons, auto-open only when nothing
  qualifies. Adds mini radar fingerprints for the top 3 qualified
  (dimension scores from the rankings endpoint, fixed axis order).
- Dashboard layout: open positions and radar side by side (xl), as in
  the mockup.
- ScoreCard: the radar fingerprint was inside the showComposite block,
  which the ticker page disables - it never rendered. Now it shows
  whenever >= 3 dimensions exist.
- TradeChart: fresh positions with < 2 bars since entry rendered no
  chart; now pads with ~10 pre-entry context bars (gray) and marks the
  entry point. First open position auto-expands so the chart is
  visible without a click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:44:55 +02:00
co-authored by Claude Fable 5
parent 20f6981712
commit 02cf9d1cba
8 changed files with 321 additions and 214 deletions
+167 -74
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useActivation } from '../hooks/useActivation';
import { useRankings } from '../hooks/useScores';
import { useTrades } from '../hooks/useTrades';
import { useWatchlist } from '../hooks/useWatchlist';
import { usePaperTrades } from '../hooks/usePaperTrades';
@@ -10,7 +11,8 @@ 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 { PriceRail, RadarChart } from '../components/charts/horizon';
import type { RadarAxis } from '../components/charts/horizon';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { tradePnl } from '../lib/paperTrade';
import {
@@ -61,6 +63,57 @@ function DirectionTag({ direction }: { direction: string }) {
);
}
interface RadarRow {
setup: TradeSetup;
rank: number;
reason: string | null;
}
/** One radar row — compact enough for the half-width column. */
function RadarSetupRow({ setup, rank, reason, name }: RadarRow & { name?: string }) {
const qualified = reason === null;
const prob = primaryTargetProbability(setup);
return (
<li
className={`grid grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 px-2 py-2.5 ${
qualified ? '' : 'opacity-60'
}`}
title={`R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · target prob ${Math.round(prob)}%` : ''}`}
>
<span className="num text-[11px] text-gray-500">{rank}</span>
<span className="min-w-0">
<Link
to={`/ticker/${setup.symbol}`}
className="block font-medium text-blue-300 transition-colors hover:text-blue-200"
>
{setup.symbol}
</Link>
{name && <span className="block truncate text-[10.5px] text-gray-500">{name}</span>}
</span>
<DirectionTag direction={setup.direction} />
<span className="flex items-center gap-2">
<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 w-11 text-[10px] text-gray-500">
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
</span>
</span>
<span className={`max-w-[170px] text-right text-[11.5px] leading-snug ${qualified ? 'text-blue-300' : 'text-gray-500'}`}>
{qualified
? `${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · ${Math.round(prob)}%` : ''}`
: reason}
</span>
</li>
);
}
function convictionLabel(action: TradeSetup['recommended_action']): string {
if (!action || action === 'NEUTRAL') return '—';
if (action.endsWith('_HIGH')) return 'High';
@@ -184,22 +237,56 @@ export default function DashboardPage() {
}, [qualifiedSetups]);
// Radar: every live setup ranked by the same score, with the gate verdict.
// Qualified setups lead (that's the actionable list); the rest sit behind a
// toggle so a 0-qualified day isn't a wall of rejections.
const radar = useMemo(() => {
if (!activation.data) return [];
return [...(trades.data ?? [])]
if (!activation.data) return { qualified: [] as RadarRow[], below: [] as RadarRow[] };
const ranked = [...(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!),
}));
return {
qualified: ranked.filter((r) => r.reason === null),
below: ranked.filter((r) => r.reason !== null).slice(0, 8),
};
}, [trades.data, activation.data]);
// Mini score fingerprints for the qualified setups (dimension scores come
// from the rankings endpoint; axis order is fixed alphabetically so shapes
// stay comparable across symbols).
const rankings = useRankings();
const fingerprints = useMemo(() => {
const dimsBySymbol = new Map<string, RadarAxis[]>();
for (const r of rankings.data?.rankings ?? []) {
const dims = [...r.dimensions].sort((a, b) => a.dimension.localeCompare(b.dimension));
if (dims.length >= 3) {
dimsBySymbol.set(
r.symbol.toUpperCase(),
dims.map((d) => ({
label: d.dimension.length > 9 ? `${d.dimension.slice(0, 8)}.` : d.dimension,
full: d.dimension,
value: d.score,
})),
);
}
}
return radar.qualified
.slice(0, 3)
.map(({ setup, rank }) => ({ symbol: setup.symbol, rank, axes: dimsBySymbol.get(setup.symbol.toUpperCase()) }))
.filter((f): f is { symbol: string; rank: number; axes: RadarAxis[] } => !!f.axes);
}, [rankings.data, radar.qualified]);
const [belowChoice, setBelowChoice] = useState<boolean | null>(null);
// Default open when nothing qualifies — the near-misses ARE the content then.
const showBelow = belowChoice ?? radar.qualified.length === 0;
const topWatchlist = useMemo(
() =>
[...(watchlist.data ?? [])]
@@ -338,78 +425,84 @@ export default function DashboardPage() {
</div>
)}
{/* Open paper trades */}
<OpenTradesPanel />
{/* Open positions | Radar — side by side like the mockup */}
<div className="grid items-start gap-8 xl:grid-cols-2">
<OpenTradesPanel />
{/* 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/${setup.symbol}`}
className="block font-medium text-blue-300 transition-colors hover:text-blue-200"
>
{setup.symbol}
</Link>
{tickerNames.get(setup.symbol.toUpperCase()) && (
<span className="block max-w-[130px] truncate text-[10.5px] text-gray-500">
{tickerNames.get(setup.symbol.toUpperCase())}
<Section title="Radar" hint="ranked by strategy score">
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.data && radar.qualified.length === 0 && radar.below.length === 0 && (
<Callout variant="empty">No live setups right now.</Callout>
)}
{(radar.qualified.length > 0 || radar.below.length > 0) && (
<div className="glass px-4 py-2">
{/* Qualified fingerprints — same axes, shapes compare at a glance */}
{fingerprints.length > 0 && (
<div className="flex flex-wrap items-end gap-4 border-b border-white/[0.06] px-2 pb-3 pt-1.5">
{fingerprints.map((f) => (
<figure key={f.symbol} className="text-center">
<RadarChart axes={f.axes} size={82} labels={false} />
<figcaption className="-mt-1">
<Link to={`/ticker/${f.symbol}`} className="block text-[13px] font-semibold text-gray-200 hover:text-blue-200">
{f.symbol}
</Link>
<span className={`num text-[9px] uppercase tracking-[0.14em] ${
f.rank === 1 ? 'text-[#ff6a45]' : 'text-gray-500'
}`}>
{f.rank === 1 ? 'top pick' : `rank ${f.rank}`}
</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>
</figcaption>
</figure>
))}
<p className="num mb-2 ml-auto max-w-[170px] text-right text-[10px] leading-relaxed text-gray-500">
qualified fingerprints · hover a corner for scores
</p>
</div>
)}
{/* Qualified rows — the actionable list */}
{radar.qualified.length > 0 ? (
<ul className="divide-y divide-white/[0.04]">
{radar.qualified.map((row) => (
<RadarSetupRow key={row.setup.id} {...row} name={tickerNames.get(row.setup.symbol.toUpperCase())} />
))}
</ul>
) : (
<p className="px-2 py-2.5 text-xs text-gray-500">
None clear the gate today the closest candidates are below.
</p>
)}
{/* Below the gate, collapsed by default when there are qualified setups */}
{radar.below.length > 0 && (
<>
<button
onClick={() => setBelowChoice(!showBelow)}
aria-expanded={showBelow}
className="flex w-full items-center gap-2 border-t border-white/[0.06] px-2 py-2.5 text-left text-xs text-gray-500 transition-colors hover:text-gray-300"
>
<span className={`text-[9px] transition-transform ${showBelow ? 'rotate-90' : ''}`} aria-hidden="true"></span>
{radar.below.length} below the gate why each doesn't qualify
</button>
{showBelow && (
<ul className="divide-y divide-white/[0.04] border-t border-white/[0.04]">
{radar.below.map((row) => (
<RadarSetupRow key={row.setup.id} {...row} name={tickerNames.get(row.setup.symbol.toUpperCase())} />
))}
</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>
</div>
)}
</Section>
)}
</Section>
</div>
{/* Watchlist — compact chips, drill into any ticker */}
<Section title="My Watchlist" hint="today's move">