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
+203 -38
View File
@@ -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 &ldquo;{searchQuery}&rdquo;.
</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 &ldquo;{searchQuery}&rdquo;.
</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