Ticker page: one unified drill-down panel, no card salad
The previous pass kept a stack of independent glass cards (header, recommendation, chart section, tab pills, per-tab sections), which read as clutter. Now the page is a single casting, like the mockup: One glass panel containing: - header (identity, price, chips | actions + score fingerprint) - inline hairline tab row (Analysis / Indicators / S/R Levels / Sentiment / Fundamentals) with the chart-overlay pills riding on the right of the row during Analysis - tab body: Analysis = the candlestick chart; other tabs render their panel inside via .hz-frameless, which strips the nested card chrome (no card-in-card) - panel foot: data freshness row (frameless now) with per-source refresh Below the panel, only on Analysis: the recommendation module and a two-column Standing | Dimensions grid. Nothing removed; the standalone Tabs bar, the separate chart card, and the duplicated section stack are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,6 @@ import { RecommendationPanel } from '../components/ticker/RecommendationPanel';
|
|||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Callout } from '../components/ui/Callout';
|
import { Callout } from '../components/ui/Callout';
|
||||||
import { Section } from '../components/ui/Section';
|
import { Section } from '../components/ui/Section';
|
||||||
import { Tabs } from '../components/ui/Tabs';
|
|
||||||
import { formatPrice } from '../lib/format';
|
import { formatPrice } from '../lib/format';
|
||||||
import type { TradeSetup } from '../lib/types';
|
import type { TradeSetup } from '../lib/types';
|
||||||
import type { FieldPoint } from '../components/ticker/StandingMatrix';
|
import type { FieldPoint } from '../components/ticker/StandingMatrix';
|
||||||
@@ -93,7 +92,7 @@ function DataFreshnessBar({
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="glass-sm p-3 flex flex-wrap gap-x-5 gap-y-2">
|
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.label} className="flex items-center gap-1.5">
|
<div key={item.label} className="flex items-center gap-1.5">
|
||||||
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${
|
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${
|
||||||
@@ -274,7 +273,8 @@ export default function TickerDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-slide-up">
|
<div className="space-y-6 animate-slide-up">
|
||||||
{/* Drill-down header — identity + chips left, score fingerprint right */}
|
{/* Drill-down header — identity + chips left, score fingerprint right */}
|
||||||
<section className="glass p-6 sm:p-7">
|
<section className="glass overflow-hidden">
|
||||||
|
<div className="p-6 pb-5 sm:p-7 sm:pb-5">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
|
<div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="num text-[10.5px] uppercase tracking-[0.28em] text-gray-500">Ticker</p>
|
<p className="num text-[10.5px] uppercase tracking-[0.28em] text-gray-500">Ticker</p>
|
||||||
@@ -360,18 +360,54 @@ export default function TickerDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<RecommendationPanel
|
{/* Tab row — inline, hairline, overlay pills ride along on Analysis */}
|
||||||
symbol={symbol}
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 border-b border-white/[0.06] px-6 sm:px-7" role="tablist" aria-label="Ticker sections">
|
||||||
longSetup={longSetup}
|
{detailTabs.map((t) => (
|
||||||
shortSetup={shortSetup}
|
<button
|
||||||
currentPrice={priceInfo?.price}
|
key={t}
|
||||||
nextEarningsDate={fundamentals.data?.next_earnings_date}
|
role="tab"
|
||||||
/>
|
aria-selected={activeTab === t}
|
||||||
|
onClick={() => setActiveTab(t)}
|
||||||
|
className={`-mb-px border-b pb-2.5 text-[13px] font-medium transition-colors ${
|
||||||
|
activeTab === t
|
||||||
|
? 'border-blue-500 text-gray-100'
|
||||||
|
: 'border-transparent text-gray-400 hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{activeTab === 'Analysis' && (longSetup || shortSetup) && (
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 pb-2 text-[11px]">
|
||||||
|
<span className="text-gray-500">overlay</span>
|
||||||
|
{([
|
||||||
|
{ key: 'auto', label: 'Auto', show: true },
|
||||||
|
{ key: 'long', label: 'Long', show: !!longSetup },
|
||||||
|
{ key: 'short', label: 'Short', show: !!shortSetup },
|
||||||
|
{ key: 'none', label: 'None', show: true },
|
||||||
|
] as const).filter((o) => o.show).map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.key}
|
||||||
|
onClick={() => setOverlayChoice(o.key)}
|
||||||
|
className={`rounded px-2 py-0.5 transition-colors ${
|
||||||
|
overlayChoice === o.key
|
||||||
|
? 'bg-blue-400/15 text-blue-200'
|
||||||
|
: 'text-gray-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Chart — always visible */}
|
{/* Tab body */}
|
||||||
<Section title="Price & Volume">
|
<div className="p-6 pt-5 sm:p-7 sm:pt-5">
|
||||||
|
{activeTab === 'Analysis' && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
|
{ohlcv.isLoading && <SkeletonCard className="h-[440px]" />}
|
||||||
{ohlcv.isError && (
|
{ohlcv.isError && (
|
||||||
<SectionError
|
<SectionError
|
||||||
@@ -380,35 +416,7 @@ export default function TickerDetailPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{ohlcv.data && (
|
{ohlcv.data && (
|
||||||
<div className="glass p-5 space-y-3">
|
<>
|
||||||
{(longSetup || shortSetup) && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
<span className="text-gray-500">Overlay setup:</span>
|
|
||||||
{([
|
|
||||||
{ key: 'auto', label: 'Auto', show: true },
|
|
||||||
{ key: 'long', label: 'Long', show: !!longSetup },
|
|
||||||
{ key: 'short', label: 'Short', show: !!shortSetup },
|
|
||||||
{ key: 'none', label: 'None', show: true },
|
|
||||||
] as const).filter((o) => o.show).map((o) => (
|
|
||||||
<button
|
|
||||||
key={o.key}
|
|
||||||
onClick={() => setOverlayChoice(o.key)}
|
|
||||||
className={`rounded-md px-2.5 py-1 transition-colors ${
|
|
||||||
overlayChoice === o.key
|
|
||||||
? 'bg-blue-400/15 text-blue-200 border border-blue-400/30'
|
|
||||||
: 'text-gray-400 border border-white/[0.08] hover:text-gray-200 hover:bg-white/[0.04]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{overlaySetup && (
|
|
||||||
<span className="num ml-1 text-gray-500">
|
|
||||||
showing {overlaySetup.direction.toUpperCase()} · entry {formatPrice(overlaySetup.entry_price)} → target {formatPrice(overlaySetup.target)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<CandlestickChart
|
<CandlestickChart
|
||||||
data={ohlcv.data}
|
data={ohlcv.data}
|
||||||
srLevels={srLevels.data?.levels}
|
srLevels={srLevels.data?.levels}
|
||||||
@@ -416,28 +424,96 @@ export default function TickerDetailPage() {
|
|||||||
tradeSetup={overlaySetup}
|
tradeSetup={overlaySetup}
|
||||||
currentPrice={priceInfo?.price}
|
currentPrice={priceInfo?.price}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="mt-2 text-[11px] text-gray-500">
|
||||||
Only the nearest support & resistance are drawn. Full list in the S/R Levels tab.
|
Only the nearest support & 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>
|
|
||||||
)}
|
)}
|
||||||
</Section>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Detail tabs */}
|
{activeTab === 'Indicators' && (
|
||||||
<Tabs tabs={detailTabs} active={activeTab} onChange={setActiveTab} />
|
<div className="hz-frameless animate-fade-in">
|
||||||
|
<IndicatorSelector symbol={symbol} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'S/R Levels' && (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{sortedLevels.length === 0 ? (
|
||||||
|
<Callout variant="empty">No S/R levels detected for this ticker yet.</Callout>
|
||||||
|
) : (
|
||||||
|
<div className="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">Type</th>
|
||||||
|
<th className="px-4 py-3">Price Level</th>
|
||||||
|
<th className="px-4 py-3">Strength</th>
|
||||||
|
<th className="px-4 py-3">Method</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedLevels.map((level) => (
|
||||||
|
<tr key={level.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={level.type === 'support' ? 'text-emerald-300' : 'text-gray-300'}>{level.type}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-200 font-mono">{formatPrice(level.price_level)}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-200">{level.strength}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">{level.detection_method}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'Sentiment' && (
|
||||||
|
<div className="hz-frameless animate-fade-in">
|
||||||
|
{sentiment.isLoading && <SkeletonCard />}
|
||||||
|
{sentiment.isError && (
|
||||||
|
<SectionError message={sentiment.error instanceof Error ? sentiment.error.message : 'Failed to load sentiment'} onRetry={() => sentiment.refetch()} />
|
||||||
|
)}
|
||||||
|
{sentiment.data && <SentimentPanel data={sentiment.data} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'Fundamentals' && (
|
||||||
|
<div className="hz-frameless animate-fade-in">
|
||||||
|
{fundamentals.isLoading && <SkeletonCard />}
|
||||||
|
{fundamentals.isError && (
|
||||||
|
<SectionError message={fundamentals.error instanceof Error ? fundamentals.error.message : 'Failed to load fundamentals'} onRetry={() => fundamentals.refetch()} />
|
||||||
|
)}
|
||||||
|
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel foot — per-source data age + refresh */}
|
||||||
|
<div className="border-t border-white/[0.06] px-6 py-3 sm:px-7">
|
||||||
|
<DataFreshnessBar
|
||||||
|
items={dataStatus}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
pendingLabel={refreshingLabel}
|
||||||
|
busy={ingestion.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{activeTab === 'Analysis' && (
|
{activeTab === 'Analysis' && (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<RecommendationPanel
|
||||||
|
symbol={symbol}
|
||||||
|
longSetup={longSetup}
|
||||||
|
shortSetup={shortSetup}
|
||||||
|
currentPrice={priceInfo?.price}
|
||||||
|
nextEarningsDate={fundamentals.data?.next_earnings_date}
|
||||||
|
/>
|
||||||
|
<div className="grid items-start gap-6 lg:grid-cols-2">
|
||||||
<Section title="Standing" hint="how this ticker ranks vs. the field">
|
<Section title="Standing" hint="how this ticker ranks vs. the field">
|
||||||
{scores.isLoading && <SkeletonCard className="h-80" />}
|
{scores.isLoading && <SkeletonCard className="h-80" />}
|
||||||
{scores.isError && (
|
{scores.isError && (
|
||||||
@@ -487,73 +563,7 @@ export default function TickerDetailPage() {
|
|||||||
<ScoreCard showComposite={false} showRadar={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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'Sentiment' && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<Section title="Sentiment">
|
|
||||||
{sentiment.isLoading && <SkeletonCard />}
|
|
||||||
{sentiment.isError && (
|
|
||||||
<SectionError message={sentiment.error instanceof Error ? sentiment.error.message : 'Failed to load sentiment'} onRetry={() => sentiment.refetch()} />
|
|
||||||
)}
|
|
||||||
{sentiment.data && <SentimentPanel data={sentiment.data} />}
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'Fundamentals' && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<Section title="Fundamentals">
|
|
||||||
{fundamentals.isLoading && <SkeletonCard />}
|
|
||||||
{fundamentals.isError && (
|
|
||||||
<SectionError message={fundamentals.error instanceof Error ? fundamentals.error.message : 'Failed to load fundamentals'} onRetry={() => fundamentals.refetch()} />
|
|
||||||
)}
|
|
||||||
{fundamentals.data && <FundamentalsPanel data={fundamentals.data} />}
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'Indicators' && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<Section title="Technical Indicators">
|
|
||||||
<IndicatorSelector symbol={symbol} />
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'S/R Levels' && (
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
<Section title="Support & Resistance Levels" hint="sorted by strength">
|
|
||||||
{sortedLevels.length === 0 ? (
|
|
||||||
<Callout variant="empty">No S/R levels detected for this ticker yet.</Callout>
|
|
||||||
) : (
|
|
||||||
<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">Type</th>
|
|
||||||
<th className="px-4 py-3">Price Level</th>
|
|
||||||
<th className="px-4 py-3">Strength</th>
|
|
||||||
<th className="px-4 py-3">Method</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedLevels.map((level) => (
|
|
||||||
<tr key={level.id} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={level.type === 'support' ? 'text-emerald-400' : 'text-red-400'}>{level.type}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-gray-200 font-mono">{formatPrice(level.price_level)}</td>
|
|
||||||
<td className="px-4 py-3 text-gray-200">{level.strength}</td>
|
|
||||||
<td className="px-4 py-3 text-gray-400">{level.detection_method}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -267,6 +267,17 @@
|
|||||||
.hz-lvl-up { fill: var(--up-text); }
|
.hz-lvl-up { fill: var(--up-text); }
|
||||||
.hz-lvl-down { fill: var(--down-text); }
|
.hz-lvl-down { fill: var(--down-text); }
|
||||||
|
|
||||||
|
/* Strip a nested panel's own card chrome when it renders inside the
|
||||||
|
unified drill-down panel (ticker page) — one casting, no card-in-card. */
|
||||||
|
.hz-frameless > .glass {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Radar fingerprint */
|
/* Radar fingerprint */
|
||||||
.hz-radar-wrap { position: relative; display: inline-block; }
|
.hz-radar-wrap { position: relative; display: inline-block; }
|
||||||
.hz-radar-axis {
|
.hz-radar-axis {
|
||||||
|
|||||||
Reference in New Issue
Block a user