Ticker page: mockup drill-down layout
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m7s
Deploy / deploy (push) Successful in 37s

Restructures TickerDetailPage to match the Horizon mockup's ticker
drill-down while keeping every existing feature:

- Glass header card: eyebrow + big symbol/company, price with change
  and freshness, chips (composite score, next earnings, top-pick /
  open-trade pills) on the left; watchlist + Fetch All actions and the
  score fingerprint radar on the right (hover a corner for values).
- Data freshness bar (per-source age + refresh buttons) moves to the
  foot of the chart card, like the mockup's panel footer.
- Tabs extended to five: Analysis, Indicators, S/R Levels, Sentiment,
  Fundamentals - sentiment and fundamentals no longer stack below the
  analysis content, they are one tab away.
- ScoreCard gains showRadar prop so the fingerprint is not duplicated
  in the Dimensions section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:59:13 +02:00
co-authored by Claude Fable 5
parent 02cf9d1cba
commit f9e12fc78c
2 changed files with 110 additions and 68 deletions
+5 -3
View File
@@ -10,6 +10,8 @@ interface ScoreCardProps {
/** Hide the composite ring/header when the composite is shown elsewhere /** Hide the composite ring/header when the composite is shown elsewhere
* (e.g. the Standing matrix) and this card only carries the dimension detail. */ * (e.g. the Standing matrix) and this card only carries the dimension detail. */
showComposite?: boolean; showComposite?: boolean;
/** Hide the radar fingerprint when the page shows it elsewhere (ticker header). */
showRadar?: boolean;
} }
function scoreColor(score: number): string { function scoreColor(score: number): string {
@@ -55,7 +57,7 @@ function ScoreRing({ score }: { score: number }) {
); );
} }
export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, showComposite = true }: ScoreCardProps) { export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, showComposite = true, showRadar = true }: ScoreCardProps) {
const [expanded, setExpanded] = useState<Record<string, boolean>>({}); const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const toggleExpand = (dimension: string) => { const toggleExpand = (dimension: string) => {
@@ -64,7 +66,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
return ( return (
<div className="glass p-5"> <div className="glass p-5">
{(showComposite || dimensions.length >= 3) && ( {(showComposite || (showRadar && dimensions.length >= 3)) && (
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
{showComposite && (compositeScore !== null ? ( {showComposite && (compositeScore !== null ? (
<ScoreRing score={compositeScore} /> <ScoreRing score={compositeScore} />
@@ -90,7 +92,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
)} )}
</div> </div>
)} )}
{dimensions.length >= 3 && ( {showRadar && dimensions.length >= 3 && (
<div <div
className={showComposite ? 'ml-auto' : 'mx-auto sm:mx-0'} className={showComposite ? 'ml-auto' : 'mx-auto sm:mx-0'}
title="Score fingerprint — hover a corner for the exact value" title="Score fingerprint — hover a corner for the exact value"
+105 -65
View File
@@ -9,6 +9,7 @@ import { useActivation } from '../hooks/useActivation';
import { topPickSymbol, qualifiesSetup } from '../lib/qualification'; import { topPickSymbol, qualifiesSetup } from '../lib/qualification';
import type { FetchSelector } from '../api/ingestion'; import type { FetchSelector } from '../api/ingestion';
import { CandlestickChart } from '../components/charts/CandlestickChart'; import { CandlestickChart } from '../components/charts/CandlestickChart';
import { RadarChart } from '../components/charts/horizon';
import { ScoreCard } from '../components/ui/ScoreCard'; import { ScoreCard } from '../components/ui/ScoreCard';
import { useTickerNames } from '../hooks/useTickers'; import { useTickerNames } from '../hooks/useTickers';
import { SkeletonCard } from '../components/ui/Skeleton'; import { SkeletonCard } from '../components/ui/Skeleton';
@@ -27,7 +28,7 @@ import type { FieldPoint } from '../components/ticker/StandingMatrix';
// Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle. // Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle.
const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix')); const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix'));
const detailTabs = ['Analysis', 'Indicators', 'S/R Levels'] as const; const detailTabs = ['Analysis', 'Indicators', 'S/R Levels', 'Sentiment', 'Fundamentals'] as const;
type DetailTab = (typeof detailTabs)[number]; type DetailTab = (typeof detailTabs)[number];
function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) { function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) {
@@ -272,70 +273,94 @@ export default function TickerDetailPage() {
return ( return (
<div className="space-y-6 animate-slide-up"> <div className="space-y-6 animate-slide-up">
{/* Header */} {/* Drill-down header — identity + chips left, score fingerprint right */}
<div className="flex flex-wrap items-center justify-between gap-4"> <section className="glass p-6 sm:p-7">
<div className="flex items-baseline gap-4"> <div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
<h1 className="text-3xl font-semibold text-gray-100">{symbol.toUpperCase()}</h1> <div className="min-w-0">
{companyName && ( <p className="num text-[10.5px] uppercase tracking-[0.28em] text-gray-500">Ticker</p>
<span className="max-w-[240px] truncate text-sm text-gray-500">{companyName}</span> <div className="mt-1.5 flex flex-wrap items-baseline gap-3">
)} <h1 className="font-display text-4xl font-bold tracking-tight text-gray-100">
{priceInfo && ( {symbol.toUpperCase()}
<div className="flex items-baseline gap-2"> </h1>
<span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span> {companyName && (
{priceInfo.change !== null && ( <span className="max-w-[260px] truncate text-sm text-gray-400">{companyName}</span>
<span className={`num text-sm font-medium ${priceInfo.change >= 0 ? 'text-emerald-400' : 'text-red-400'}`}> )}
{priceInfo.change >= 0 ? '+' : ''}{priceInfo.change.toFixed(2)}% </div>
{priceInfo && (
<div className="mt-2 flex flex-wrap items-baseline gap-2.5">
<span className="num text-2xl font-semibold text-gray-100">{formatPrice(priceInfo.price)}</span>
{priceInfo.change !== null && (
<span className={`num text-sm font-medium ${priceInfo.change >= 0 ? 'text-emerald-300' : 'text-red-300'}`}>
{priceInfo.change >= 0 ? '+' : ''}{priceInfo.change.toFixed(2)}%
</span>
)}
<span className="text-xs text-gray-500">last close · {timeAgo(priceInfo.date)}</span>
</div>
)}
<div className="mt-4 flex flex-wrap items-center gap-2">
{scores.data?.composite_score != null && (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
composite score {Math.round(scores.data.composite_score)}
</span> </span>
)} )}
<span className="text-xs text-gray-500">last close · {timeAgo(priceInfo.date)}</span> {fundamentals.data?.next_earnings_date && (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
earnings {new Date(fundamentals.data.next_earnings_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
)}
{isTopPick && (
<StatusPill
tone="blue"
label="★ Top Pick"
title="Current top pick - highest production-ranked qualified setup right now"
/>
)}
{hasOpenTrade && (
<StatusPill
tone="emerald"
label="● Open Trade"
title="You have an open paper trade on this ticker"
/>
)}
</div> </div>
)} </div>
</div>
<div className="flex items-center gap-2">
{isTopPick && (
<StatusPill
tone="blue"
label="★ Top Pick"
title="Current top pick - highest production-ranked qualified setup right now"
/>
)}
{hasOpenTrade && (
<StatusPill
tone="emerald"
label="● Open Trade"
title="You have an open paper trade on this ticker"
/>
)}
<Button
variant="ghost"
onClick={() =>
onWatchlist
? removeFromWatchlist.mutate(symbol)
: addToWatchlist.mutate(symbol)
}
loading={watchlistBusy}
disabled={watchlist.isLoading}
title={onWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
className={onWatchlist ? '!text-amber-300' : ''}
>
{onWatchlist ? '★ Watching' : '☆ Add to watchlist'}
</Button>
<Button
onClick={() => { setRefreshingLabel(null); ingestion.mutate(symbol); }}
loading={ingestion.isPending}
>
{ingestion.isPending ? 'Fetching…' : 'Fetch All'}
</Button>
</div>
</div>
{/* Data freshness bar */} <div className="flex flex-col items-end gap-1">
<DataFreshnessBar <div className="flex items-center gap-2">
items={dataStatus} <Button
onRefresh={handleRefresh} variant="ghost"
pendingLabel={refreshingLabel} onClick={() =>
busy={ingestion.isPending} onWatchlist
/> ? removeFromWatchlist.mutate(symbol)
: addToWatchlist.mutate(symbol)
}
loading={watchlistBusy}
disabled={watchlist.isLoading}
title={onWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}
className={onWatchlist ? '!text-amber-300' : ''}
>
{onWatchlist ? '★ Watching' : '☆ Add to watchlist'}
</Button>
<Button
onClick={() => { setRefreshingLabel(null); ingestion.mutate(symbol); }}
loading={ingestion.isPending}
>
{ingestion.isPending ? 'Fetching…' : 'Fetch All'}
</Button>
</div>
{scores.data && scores.data.dimensions.length >= 3 && (
<RadarChart
axes={scores.data.dimensions.map((d) => ({
label: d.dimension.length > 9 ? `${d.dimension.slice(0, 8)}.` : d.dimension,
full: d.dimension,
value: d.score,
}))}
size={112}
/>
)}
</div>
</div>
</section>
<RecommendationPanel <RecommendationPanel
symbol={symbol} symbol={symbol}
@@ -395,6 +420,15 @@ export default function TickerDetailPage() {
Only the nearest support &amp; resistance are drawn. Full list in the S/R Levels tab. Only the nearest support &amp; resistance are drawn. Full list in the S/R Levels tab.
{srLevels.isError && ' S/R levels unavailable.'} {srLevels.isError && ' S/R levels unavailable.'}
</p> </p>
{/* Data freshness — per-source age + refresh, at the panel foot like the mockup */}
<div className="border-t border-white/[0.06] pt-3">
<DataFreshnessBar
items={dataStatus}
onRefresh={handleRefresh}
pendingLabel={refreshingLabel}
busy={ingestion.isPending}
/>
</div>
</div> </div>
)} )}
</Section> </Section>
@@ -444,17 +478,20 @@ export default function TickerDetailPage() {
)} )}
</Section> </Section>
<div className="grid gap-6 lg:grid-cols-3"> <Section title="Dimensions" hint="fingerprint values in the header · expand a row for the formula">
<Section title="Dimensions">
{scores.isLoading && <SkeletonCard />} {scores.isLoading && <SkeletonCard />}
{scores.isError && ( {scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} /> <SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)} )}
{scores.data && ( {scores.data && (
<ScoreCard showComposite={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} /> <ScoreCard showComposite={false} showRadar={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
)} )}
</Section> </Section>
</div>
)}
{activeTab === 'Sentiment' && (
<div className="animate-fade-in">
<Section title="Sentiment"> <Section title="Sentiment">
{sentiment.isLoading && <SkeletonCard />} {sentiment.isLoading && <SkeletonCard />}
{sentiment.isError && ( {sentiment.isError && (
@@ -462,7 +499,11 @@ export default function TickerDetailPage() {
)} )}
{sentiment.data && <SentimentPanel data={sentiment.data} />} {sentiment.data && <SentimentPanel data={sentiment.data} />}
</Section> </Section>
</div>
)}
{activeTab === 'Fundamentals' && (
<div className="animate-fade-in">
<Section title="Fundamentals"> <Section title="Fundamentals">
{fundamentals.isLoading && <SkeletonCard />} {fundamentals.isLoading && <SkeletonCard />}
{fundamentals.isError && ( {fundamentals.isError && (
@@ -470,7 +511,6 @@ export default function TickerDetailPage() {
)} )}
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />} {fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
</Section> </Section>
</div>
</div> </div>
)} )}