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:
@@ -19,6 +19,9 @@ import { nextRunLabelET } from '@/lib/pipelineSchedule';
|
||||
// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first
|
||||
// paint ALWAYS proves the model (#1, #13, Part 6).
|
||||
import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter';
|
||||
// Wave 4A (Step 3) — OUTLOOK MODE: the "Today's games" empty branch is never a
|
||||
// dead-end CTA; it shows yesterday's proven receipts + tomorrow's real schedule.
|
||||
import { buildOutlook } from '@/lib/outlook';
|
||||
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||
import { currentAccessToken } from '@/lib/authToken';
|
||||
|
||||
@@ -109,6 +112,98 @@ const SPORT_COLOR: Record<Sport, string> = {
|
||||
WNBA: '#FFB347',
|
||||
};
|
||||
|
||||
/**
|
||||
* Wave 4A (Step 3) — the dashboard OUTLOOK surface for an empty slate. Replaces
|
||||
* the old "NO SLATE" dead-end CTA: the month-aware header + yesterday's PROVEN
|
||||
* A-tier receipts + tomorrow's date-pinned schedule preview (all REAL,
|
||||
* always-available data — never an invented line). Self-fetches both; the
|
||||
* header always renders, so the grid is never blank.
|
||||
*/
|
||||
function DashboardOutlook({ sport }: { sport: Sport }) {
|
||||
const [settled, setSettled] = useState<ModelEntry[] | null>(null);
|
||||
const [tomorrow, setTomorrow] = useState<ScheduleApiGame[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`)
|
||||
.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; };
|
||||
}, [sport]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
// Tomorrow, ET — the schedule route is date-pinned (free / cached).
|
||||
const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(Date.now() + 86_400_000));
|
||||
fetch(`/api/schedule/${sport.toLowerCase()}?date=${date}`)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => { if (active) setTomorrow((Array.isArray(d?.games) ? d.games : []).map((g: ScheduleApiGame) => ({ ...g, sport: sport.toLowerCase() }))); })
|
||||
.catch(() => { if (active) setTomorrow([]); });
|
||||
return () => { active = false; };
|
||||
}, [sport]);
|
||||
|
||||
const outlook = buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceiptView[]; tomorrow?: OutlookGameView[] };
|
||||
const receipts: OutlookReceiptView[] = outlook.receipts ?? [];
|
||||
const preview: OutlookGameView[] = outlook.tomorrow ?? [];
|
||||
const { title, body } = emptyStateCopy(sport.toLowerCase());
|
||||
|
||||
return (
|
||||
<div className="surface diagonal-cut tex-scan" style={{ padding: 24, display: 'grid', gap: 16 }}>
|
||||
<div>
|
||||
<p className="mono lbl" style={{ color: 'var(--amber)' }}>OUTLOOK</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700, marginTop: 4 }}>{title}</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 460 }}>{body}</p>
|
||||
</div>
|
||||
|
||||
{receipts.length > 0 && (
|
||||
<div>
|
||||
<div className="mono lbl" style={{ color: 'var(--grade-a)', marginBottom: 10 }}>YESTERDAY · PROVEN</div>
|
||||
<div style={{ display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 4 }}>
|
||||
{receipts.map((r: OutlookReceiptView, i: number) => (
|
||||
<div key={`${r.player}-${r.stat}-${i}`} className="surface" style={{ minWidth: 190, padding: 14, border: '1px solid var(--grade-a)', borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span className="mono" style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.1em', color: 'var(--grade-a)' }}>{String(r.sport).toUpperCase()}</span>
|
||||
<GradeBadge grade={r.grade} size={28} glow />
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 3 }}>{r.player}</div>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)', textTransform: 'capitalize', fontVariantNumeric: 'tabular-nums' }}>
|
||||
{String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')}
|
||||
</p>
|
||||
<p className="mono" style={{ fontSize: 12, fontWeight: 800, color: 'var(--grade-a)', marginTop: 6, fontVariantNumeric: 'tabular-nums' }}>
|
||||
✓ HIT{r.actual != null ? ` (${r.actual})` : ''}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview.length > 0 && (
|
||||
<div>
|
||||
<div className="mono lbl" style={{ color: 'var(--text-tertiary)', marginBottom: 10 }}>TOMORROW · SCHEDULE — lines post on the day</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{preview.map((g: OutlookGameView) => (
|
||||
<div key={g.id} className="mono" style={{ display: 'flex', gap: 10, alignItems: 'baseline', fontSize: 13, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontWeight: 700 }}>{g.away} @ {g.home}</span>
|
||||
{g.time && <span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{formatTime(g.time)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger →</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render-only views of the buildOutlook output (the JS adapter has no TS types).
|
||||
interface OutlookReceiptView { player: string; stat: string; line: number; side: string; grade: string; sport: string; actual: number | null }
|
||||
interface OutlookGameView { id: string; away: string; home: string; time: string | null }
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user, session, tier, scansRemaining, loading: authLoading } = useAuth();
|
||||
@@ -431,21 +526,9 @@ export default function DashboardPage() {
|
||||
{games === null ? (
|
||||
<SkeletonRow stacked />
|
||||
) : games.length === 0 ? (
|
||||
// Session 57 (Phase 0) — honest per-sport empty state (spec §6):
|
||||
// an off-season sport says when it returns; in-season = off-day.
|
||||
<div
|
||||
className="surface diagonal-cut tex-scan"
|
||||
style={{ padding: 32, textAlign: 'center', display: 'grid', gap: 8, justifyItems: 'center' }}
|
||||
>
|
||||
<p className="lbl" style={{ color: 'var(--grade-c)' }}>NO SLATE</p>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700 }}>{emptyStateCopy(sport.toLowerCase()).title}</h3>
|
||||
<p style={{ color: 'var(--text-1)', fontSize: 14, maxWidth: 420 }}>
|
||||
{emptyStateCopy(sport.toLowerCase()).body}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
||||
<a href="/ledger" className="btn-primary" style={{ padding: '10px 18px' }}>View the Ledger →</a>
|
||||
</div>
|
||||
</div>
|
||||
// Wave 4A (Step 3) — OUTLOOK MODE: never a dead-end "NO SLATE" CTA.
|
||||
// Yesterday's proven receipts + tomorrow's real schedule fill the grid.
|
||||
<DashboardOutlook sport={sport} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{games.map((g, idx) => (
|
||||
|
||||
@@ -83,6 +83,12 @@ export default function DeskShowcase() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wave 4A (Step 4) — this claim is now BACKED by a real feature: the
|
||||
CONSENSUS vs MODEL strip (components/vyndr/MarketBreadth, fed by
|
||||
lib/marketBreadth.collectBreadth) ships on the live slate/dashboard,
|
||||
computing the median book line vs the model's projection. "live
|
||||
line moves" is the existing snapshot line-deltas / LineSparkline.
|
||||
No longer an empty promise — do not remove without removing those. */}
|
||||
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span className="live-dot" style={{ background: 'var(--g-a)' }} aria-hidden />
|
||||
<span className="mono" style={{ fontSize: 11.5, color: 'var(--text-1)', letterSpacing: '0.04em' }}>
|
||||
|
||||
Reference in New Issue
Block a user