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:
Kev
2026-07-13 14:59:44 -04:00
parent 35007cde22
commit bc8633466c
10 changed files with 824 additions and 53 deletions
+98 -15
View File
@@ -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' }}>
&#10003; 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) => (