Wave 4A: Outlook Mode (never-empty grid) + Market-Breadth consensus strip
Step 3 — OUTLOOK MODE. The game grid no longer dead-ends in a "NO SLATE" CTA. When there are no live games (and it's not a network failure) it shows REAL, always-available data: yesterday's PROVEN A-tier receipts (/api/ledger/model) + tomorrow's date-pinned ESPN schedule preview (free/cached). A network fetchError stays a distinct ERROR state — never a fabricated outlook. - lib/outlook.js (new, CommonJS, unit-tested): buildOutlook selection + mapTomorrowPreview (upcoming-only, drops incomplete matchups, never invents). - Slate.tsx: OutlookSurface replaces the empty-grid CTA (dateOffset 0 only). - dashboard/page.tsx: DashboardOutlook replaces the "Today's games" NO-SLATE CTA. Step 4 — MARKET-BREADTH / CONSENSUS vs MODEL. Makes the DeskShowcase "consensus vs model" claim REAL. Consensus = median book line across a prop's per-book rows; the model's position is model_value vs consensus, signed by the graded side. <2 distinct books → null (never fabricate a consensus); a non-numeric line is ignored, never coerced to 0. - lib/marketBreadth.js (new, CommonJS, unit-tested): median/computeBreadth/ collectBreadth (strict null guards). - components/vyndr/MarketBreadth.tsx (new): mono/tabular strip, colored by sign via colorContract.edgeColor, self-hides when nothing has >=2 books. - Slate.tsx renders it above the grid (joins books + snapshot model_value). - slateAdapter.js exports gradeKey for the join. - DeskShowcase.tsx: the consensus claim is now backed by the shipped feature. Tests: tests/unit/outlook.test.js + tests/unit/marketBreadth.test.js (23 cases). Full suite 2984 passing (245 suites); next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+203
-38
@@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation';
|
||||
import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
|
||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter';
|
||||
// Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no
|
||||
// live games (and it's not a fetch failure) the grid shows REAL data —
|
||||
// yesterday's proven receipts + tomorrow's date-pinned schedule.
|
||||
import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook';
|
||||
// Wave 4A (Step 4) — CONSENSUS vs MODEL: median-book-line vs the model.
|
||||
import { collectBreadth } from '@/lib/marketBreadth';
|
||||
import MarketBreadth from '@/components/vyndr/MarketBreadth';
|
||||
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
|
||||
// Grades never change in-game; these marks are tracking, labeled as such.
|
||||
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
|
||||
@@ -165,7 +172,7 @@ interface StreakApiRow {
|
||||
interface StreaksResponse { streaks?: StreakApiRow[] }
|
||||
|
||||
// Session 45 — pre-graded snapshot response (snapshot:{sport}:latest).
|
||||
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
|
||||
interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; projection?: number | null; model_value?: number | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null }
|
||||
interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number }
|
||||
interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] }
|
||||
|
||||
@@ -419,6 +426,119 @@ function YesterdaySettle({ date }: { date: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// The /api/ledger/model settled-row shape (subset the receipts read).
|
||||
interface ModelReceiptRow {
|
||||
player_name?: string; player?: string; sport?: string; stat?: string;
|
||||
line?: number; side?: string; grade?: string;
|
||||
outcome?: string | null; actual_value?: number | null; clv_result?: string | null;
|
||||
}
|
||||
// buildHeroReceipts output (proven yesterday hit).
|
||||
interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null }
|
||||
// mapTomorrowPreview output.
|
||||
interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null }
|
||||
|
||||
/**
|
||||
* Wave 4A (Step 3) — OUTLOOK MODE surface. Renders in the empty game grid in
|
||||
* place of the old dead-end CTA: yesterday's PROVEN A-tier receipts +
|
||||
* tomorrow's date-pinned schedule preview (both REAL, always-available data —
|
||||
* never an invented line). The month-aware header ALWAYS shows, so the grid is
|
||||
* never blank. Distinct from the network `fetchError` state.
|
||||
*/
|
||||
function OutlookSurface({ tab }: { tab: SlateTab }) {
|
||||
const [settled, setSettled] = useState<ModelReceiptRow[] | null>(null);
|
||||
const [tomorrow, setTomorrow] = useState<ScheduleGame[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const sportQ = tab !== 'all' && tab !== 'soccer' ? `&sport=${tab}` : '';
|
||||
fetch(`/api/ledger/model?limit=80${sportQ}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); })
|
||||
.catch(() => { if (active) setSettled([]); });
|
||||
return () => { active = false; };
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const date = etDateWithOffset(1); // tomorrow, ET — the schedule route is date-pinned
|
||||
const SPORTS: SlateSport[] = tab === 'all'
|
||||
? ['mlb', 'nba', 'wnba']
|
||||
: (['nba', 'wnba', 'mlb'] as string[]).includes(tab) ? [tab as SlateSport] : [];
|
||||
if (SPORTS.length === 0) { setTomorrow([]); return; }
|
||||
Promise.all(SPORTS.map(async (sport) => {
|
||||
try {
|
||||
const r = await fetch(`/api/schedule/${sport}?date=${date}`, { cache: 'no-store' });
|
||||
if (!r.ok) return [] as ScheduleGame[];
|
||||
const d = (await r.json()) as ScheduleResponse;
|
||||
return (Array.isArray(d?.games) ? d.games : []).map((g) => ({ ...g, sport }));
|
||||
} catch { return [] as ScheduleGame[]; }
|
||||
})).then((lists) => { if (active) setTomorrow(lists.flat()); });
|
||||
return () => { active = false; };
|
||||
}, [tab]);
|
||||
|
||||
const outlook = useMemo(
|
||||
() => buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceipt[]; tomorrow?: OutlookGame[] },
|
||||
[settled, tomorrow],
|
||||
);
|
||||
const receipts: OutlookReceipt[] = outlook.receipts ?? [];
|
||||
const preview: OutlookGame[] = outlook.tomorrow ?? [];
|
||||
const { title, body } = emptyStateCopy(tab);
|
||||
|
||||
return (
|
||||
<section className="surface" style={{ border: '1px solid var(--border, #1A1A24)', borderRadius: 8, padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: receipts.length || preview.length ? 18 : 0 }}>
|
||||
<p className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.16em', color: 'var(--amber, #FFB347)', marginBottom: 6 }}>OUTLOOK</p>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 4 }}>{title}</p>
|
||||
<p style={{ color: 'var(--text-secondary, #8A8A9A)', fontSize: 13 }}>{body}</p>
|
||||
</div>
|
||||
|
||||
{receipts.length > 0 && (
|
||||
<div style={{ marginBottom: preview.length ? 18 : 0 }}>
|
||||
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
|
||||
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
|
||||
{receipts.map((r, i) => (
|
||||
<div
|
||||
key={`${r.player}-${r.stat}-${i}`}
|
||||
className="mono"
|
||||
style={{ minWidth: 178, padding: 12, border: '1px solid var(--g-a)', borderRadius: 10, background: 'var(--bg-surface, #101018)', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)' }}>{String(r.sport || '').toUpperCase()}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: 'var(--g-a)' }}>{r.grade}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)', marginBottom: 3 }}>{r.player}</div>
|
||||
<div style={{ fontSize: 11.5, color: 'var(--text-1)' }}>
|
||||
{String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')}
|
||||
</div>
|
||||
<div style={{ fontSize: 11.5, fontWeight: 800, color: 'var(--g-a)', marginTop: 6 }}>
|
||||
✓ HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' · CLV BEAT' : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.length > 0 && (
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 10, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--text-2)', marginBottom: 10 }}>
|
||||
TOMORROW · SCHEDULE <span style={{ color: 'var(--text-2)', fontWeight: 600 }}>— lines post on the day</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 7 }}>
|
||||
{preview.map((g) => (
|
||||
<div key={g.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
|
||||
{g.sport && <span style={{ fontSize: 8.5, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--text-2)', minWidth: 30 }}>{g.sport}</span>}
|
||||
<span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{g.away} @ {g.home}</span>
|
||||
{g.time && <span style={{ color: 'var(--text-2)' }}>{formatGameTime(g.time)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SlateProps {
|
||||
initialTab?: SlateTab;
|
||||
tier?: Tier;
|
||||
@@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
)) as SlateGame[];
|
||||
}, [filteredGames, gradeIndex, liveIndex]);
|
||||
|
||||
// Wave 4A (Step 4) — CONSENSUS vs MODEL breadth. For every graded prop that
|
||||
// carries ≥2 book lines, compare the market's median line to the model's
|
||||
// projection (signed by the graded side). collectBreadth drops <2-book props
|
||||
// and ranks by |edge| — an empty result self-hides the strip. This is the
|
||||
// REAL data behind the DeskShowcase "consensus vs model" claim.
|
||||
const breadthItems = useMemo(() => {
|
||||
const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = [];
|
||||
for (const g of filteredGames) {
|
||||
for (const p of g.props) {
|
||||
if (!Array.isArray(p.books) || p.books.length < 2) continue;
|
||||
const grade = (gradeIndex as Record<string, SnapshotGrade>)[gradeKey(p.player, p.stat_type)];
|
||||
const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null;
|
||||
items.push({
|
||||
player: p.player,
|
||||
stat: p.stat_type,
|
||||
side: (grade && grade.direction) || p.direction || 'over',
|
||||
line: p.line,
|
||||
books: p.books,
|
||||
modelValue: mv == null ? null : Number(mv),
|
||||
});
|
||||
}
|
||||
}
|
||||
return collectBreadth(items, 6);
|
||||
}, [filteredGames, gradeIndex]);
|
||||
|
||||
// Session 25 — per-sport game counts for the tab labels, derived from
|
||||
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
|
||||
// free ESPN schedule even when odds are empty. Counts only appear for
|
||||
@@ -965,7 +1110,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && filteredGames.length === 0 && (
|
||||
{/* A search miss keeps its own scan-it CTA. */}
|
||||
{!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
|
||||
<div
|
||||
className="surface"
|
||||
style={{
|
||||
@@ -976,46 +1122,65 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
}}
|
||||
>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
No props found for “{searchQuery}”.
|
||||
</p>
|
||||
<a
|
||||
href={manualScanHref}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Scan it manually →
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
// Session 57 (Phase 0) — honest per-sport empty copy (spec §6):
|
||||
// off-season sports name their return window; in-season = off-day.
|
||||
(() => {
|
||||
const { title, body } = emptyStateCopy(tab);
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
||||
<p>{body}</p>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
No props found for “{searchQuery}”.
|
||||
</p>
|
||||
<a
|
||||
href={manualScanHref}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
background: 'var(--grade-a, #00D4A0)',
|
||||
color: 'var(--bg-0, #0A0A0F)',
|
||||
borderRadius: 4,
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Scan it manually →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wave 4A (Step 3) — OUTLOOK MODE. Today's grid is never a dead-end CTA:
|
||||
when there are no live games (and no search, no fetch failure) it shows
|
||||
yesterday's proven receipts + tomorrow's real schedule. Yesterday/
|
||||
Tomorrow date nav keep the plain honest copy (those are explicit date
|
||||
surfaces; -1 already has THE SETTLE below). */}
|
||||
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && (
|
||||
<OutlookSurface tab={tab} />
|
||||
)}
|
||||
{!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && (
|
||||
<div
|
||||
className="surface"
|
||||
style={{
|
||||
padding: 28,
|
||||
border: '1px solid var(--border, #1A1A24)',
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary, #8A8A9A)',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const { title, body } = emptyStateCopy(tab);
|
||||
return (
|
||||
<>
|
||||
<p style={{ fontWeight: 700, color: 'var(--text-primary, #EDEDF2)', marginBottom: 6 }}>{title}</p>
|
||||
<p>{body}</p>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
|
||||
|
||||
{/* Wave 4A (Step 4) — the CONSENSUS vs MODEL strip. Self-hides unless a
|
||||
graded prop has a real ≥2-book median to compare the model against. */}
|
||||
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{orderedGames.map((g, i) => (
|
||||
<VyndrGameCard
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* MarketBreadth (Wave 4A · Step 4) — the CONSENSUS-vs-MODEL strip that makes
|
||||
* the DeskShowcase "consensus vs model, live line moves" claim REAL.
|
||||
*
|
||||
* Fed pre-computed breadth rows (from `lib/marketBreadth.collectBreadth`): each
|
||||
* row carries the market CONSENSUS (median book line across ≥2 books) and the
|
||||
* model's position vs it, SIGNED by the graded side. Colored by the ONE color
|
||||
* contract (`edgeColor`): model-beats-market → signal-green, model-behind →
|
||||
* muted red, inline → neutral. Mono, tabular — it's data.
|
||||
*
|
||||
* SELF-HIDING: no rows (nothing has ≥2 books) → renders null. It never shows an
|
||||
* empty promise; the honest surface only appears when there's a real consensus.
|
||||
*/
|
||||
|
||||
import SectionHead from './SectionHead';
|
||||
import { edgeColor } from '@/lib/colorContract';
|
||||
|
||||
export interface BreadthRow {
|
||||
player?: string;
|
||||
stat?: string;
|
||||
line?: number | null;
|
||||
consensus: number | null;
|
||||
bookCount: number;
|
||||
model: number | null;
|
||||
delta: number | null;
|
||||
signedEdge: number | null;
|
||||
// Widened to string|null: collectBreadth is untyped JS, so its inferred
|
||||
// return widens these; the values are only ever 'above'|'below'|'inline'.
|
||||
position: string | null;
|
||||
side: string;
|
||||
}
|
||||
|
||||
const STAT_LABEL: Record<string, string> = {
|
||||
total_bases: 'TB', home_runs: 'HR', hits: 'H', rbi: 'RBI', runs: 'R',
|
||||
strikeouts: 'K', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP',
|
||||
stolen_bases: 'SB', points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT',
|
||||
};
|
||||
function statLabel(stat?: string): string {
|
||||
if (!stat) return '';
|
||||
return STAT_LABEL[stat] || String(stat).replace(/_/g, ' ').toUpperCase();
|
||||
}
|
||||
|
||||
function fmtSigned(n: number | null): string {
|
||||
if (n == null) return '';
|
||||
const s = n > 0 ? '+' : n < 0 ? '' : '±';
|
||||
return `${s}${n}`;
|
||||
}
|
||||
|
||||
export default function MarketBreadth({
|
||||
items,
|
||||
title = 'CONSENSUS vs MODEL',
|
||||
max = 6,
|
||||
}: {
|
||||
items?: BreadthRow[] | null;
|
||||
title?: string;
|
||||
max?: number;
|
||||
}) {
|
||||
const rows = (Array.isArray(items) ? items : []).slice(0, Math.max(0, max));
|
||||
if (rows.length === 0) return null; // self-hide — no honest consensus to show
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
background: 'var(--bg-1)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<SectionHead style={{ marginBottom: 12 }}>
|
||||
{title}
|
||||
<span className="mono" style={{ color: 'var(--text-2)', fontSize: 9, marginLeft: 8, letterSpacing: '0.14em' }}>
|
||||
MEDIAN BOOK LINE · MODEL EDGE
|
||||
</span>
|
||||
</SectionHead>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{rows.map((r, i) => {
|
||||
const col = edgeColor(r.signedEdge);
|
||||
const sideChar = r.side === 'under' ? 'u' : 'o';
|
||||
return (
|
||||
<div
|
||||
key={`${r.player}-${r.stat}-${i}`}
|
||||
className="mono"
|
||||
style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap', fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{r.player && <span style={{ color: 'var(--text-0)', fontWeight: 700, minWidth: 96 }}>{r.player}</span>}
|
||||
<span style={{ color: 'var(--text-1)' }}>
|
||||
{statLabel(r.stat)} {sideChar}{r.line ?? r.consensus}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
CONSENSUS <span style={{ color: 'var(--text-0)' }}>{r.consensus}</span>
|
||||
<span style={{ color: 'var(--text-2)' }}> · {r.bookCount} BOOKS</span>
|
||||
</span>
|
||||
{r.model != null ? (
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
MODEL <span style={{ color: 'var(--text-0)' }}>{r.model}</span>
|
||||
{r.signedEdge != null && (
|
||||
<span style={{ color: col, fontWeight: 800 }}> · {fmtSigned(r.signedEdge)}</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--text-2)' }}>MODEL —</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export { default as Card } from './Card';
|
||||
export { default as Sparkline } from './Sparkline';
|
||||
export { default as Ticker } from './Ticker';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as MarketBreadth } from './MarketBreadth';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
export { default as GradeResultCard } from './GradeResultCard';
|
||||
export type { GradeResultData } from './GradeResultCard';
|
||||
|
||||
Reference in New Issue
Block a user