Ticker page: mockup drill-down layout
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:
@@ -10,6 +10,8 @@ interface ScoreCardProps {
|
||||
/** Hide the composite ring/header when the composite is shown elsewhere
|
||||
* (e.g. the Standing matrix) and this card only carries the dimension detail. */
|
||||
showComposite?: boolean;
|
||||
/** Hide the radar fingerprint when the page shows it elsewhere (ticker header). */
|
||||
showRadar?: boolean;
|
||||
}
|
||||
|
||||
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 toggleExpand = (dimension: string) => {
|
||||
@@ -64,7 +66,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
{(showComposite || dimensions.length >= 3) && (
|
||||
{(showComposite || (showRadar && dimensions.length >= 3)) && (
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{showComposite && (compositeScore !== null ? (
|
||||
<ScoreRing score={compositeScore} />
|
||||
@@ -90,7 +92,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{dimensions.length >= 3 && (
|
||||
{showRadar && dimensions.length >= 3 && (
|
||||
<div
|
||||
className={showComposite ? 'ml-auto' : 'mx-auto sm:mx-0'}
|
||||
title="Score fingerprint — hover a corner for the exact value"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useActivation } from '../hooks/useActivation';
|
||||
import { topPickSymbol, qualifiesSetup } from '../lib/qualification';
|
||||
import type { FetchSelector } from '../api/ingestion';
|
||||
import { CandlestickChart } from '../components/charts/CandlestickChart';
|
||||
import { RadarChart } from '../components/charts/horizon';
|
||||
import { ScoreCard } from '../components/ui/ScoreCard';
|
||||
import { useTickerNames } from '../hooks/useTickers';
|
||||
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.
|
||||
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];
|
||||
|
||||
function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||
@@ -272,26 +273,41 @@ export default function TickerDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
{/* Header */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<h1 className="text-3xl font-semibold text-gray-100">{symbol.toUpperCase()}</h1>
|
||||
{/* Drill-down header — identity + chips left, score fingerprint right */}
|
||||
<section className="glass p-6 sm:p-7">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
|
||||
<div className="min-w-0">
|
||||
<p className="num text-[10.5px] uppercase tracking-[0.28em] text-gray-500">Ticker</p>
|
||||
<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">
|
||||
{symbol.toUpperCase()}
|
||||
</h1>
|
||||
{companyName && (
|
||||
<span className="max-w-[240px] truncate text-sm text-gray-500">{companyName}</span>
|
||||
<span className="max-w-[260px] truncate text-sm text-gray-400">{companyName}</span>
|
||||
)}
|
||||
</div>
|
||||
{priceInfo && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<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-400' : 'text-red-400'}`}>
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
)}
|
||||
{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"
|
||||
@@ -306,6 +322,11 @@ export default function TickerDetailPage() {
|
||||
title="You have an open paper trade on this ticker"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
@@ -327,15 +348,19 @@ export default function TickerDetailPage() {
|
||||
{ingestion.isPending ? 'Fetching…' : 'Fetch All'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data freshness bar */}
|
||||
<DataFreshnessBar
|
||||
items={dataStatus}
|
||||
onRefresh={handleRefresh}
|
||||
pendingLabel={refreshingLabel}
|
||||
busy={ingestion.isPending}
|
||||
{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
|
||||
symbol={symbol}
|
||||
@@ -395,6 +420,15 @@ export default function TickerDetailPage() {
|
||||
Only the nearest support & resistance are drawn. Full list in the S/R Levels tab.
|
||||
{srLevels.isError && ' S/R levels unavailable.'}
|
||||
</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>
|
||||
)}
|
||||
</Section>
|
||||
@@ -444,17 +478,20 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Section title="Dimensions">
|
||||
<Section title="Dimensions" hint="fingerprint values in the header · expand a row for the formula">
|
||||
{scores.isLoading && <SkeletonCard />}
|
||||
{scores.isError && (
|
||||
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Sentiment' && (
|
||||
<div className="animate-fade-in">
|
||||
<Section title="Sentiment">
|
||||
{sentiment.isLoading && <SkeletonCard />}
|
||||
{sentiment.isError && (
|
||||
@@ -462,7 +499,11 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
{sentiment.data && <SentimentPanel data={sentiment.data} />}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Fundamentals' && (
|
||||
<div className="animate-fade-in">
|
||||
<Section title="Fundamentals">
|
||||
{fundamentals.isLoading && <SkeletonCard />}
|
||||
{fundamentals.isError && (
|
||||
@@ -471,7 +512,6 @@ export default function TickerDetailPage() {
|
||||
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'Indicators' && (
|
||||
|
||||
Reference in New Issue
Block a user