From 9b9aab42627b1987e93654b5c9a040984baa7b38 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 18 Jul 2026 01:29:55 -0400 Subject: [PATCH] =?UTF-8?q?Item=205=20=E2=80=94=20daily=20hero=20prop=20is?= =?UTF-8?q?=20a=20live=20RULE=20(biggest=20model-vs-market=20disagreement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing hero was a static Jokic "Example" card with a name-length pick and a hardcoded A- 73% +6.2% fallback. Now it's deterministic and live: - heroPropService.pickHeroProp reads the pre-graded snapshot and selects the prop with the LARGEST |projection - line| gap among A/B grades (conviction, not noise) — the read where VYNDR disagrees most with the market, the card that makes a stranger argue. No curation, no grading (reads cache → no API credits). GET /api/hero-prop (backend) + repointed Next proxy. - The card shows the disagreement EXPLICITLY: the book's line vs VYNDR's model, side by side (model in green), with the real grade timestamp ("Graded 2:14 PM"). The EXAMPLE chip is gone. - Empty slate → the MOST RECENT real graded read (flagged "LATEST READ", real date). Nothing cached → { available:false } and the card HIDES. No hand-written fallback — the Jokic card is deleted. Survives a dead night: a live rule shows tonight's real MLB read, never a phantom July NBA card. 7 service tests lock the rule (max-gap, A/B gate, projection/line required, empty→recent, hidden, cross-sport). colorContract updated to the new disagreement display. Change-affected suites green, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 1 + src/routes/heroProp.js | 29 +++ src/services/heroPropService.js | 93 +++++++ tests/unit/colorContract.test.js | 17 +- tests/unit/heroPropService.test.js | 85 ++++++ web/src/app/api/hero-prop/route.ts | 178 ++----------- web/src/components/Hero.tsx | 11 +- web/src/components/LiveHeroProp.tsx | 391 ++++++++-------------------- 8 files changed, 345 insertions(+), 460 deletions(-) create mode 100644 src/routes/heroProp.js create mode 100644 src/services/heroPropService.js create mode 100644 tests/unit/heroPropService.test.js diff --git a/src/app.js b/src/app.js index 045cdc3..ec23499 100644 --- a/src/app.js +++ b/src/app.js @@ -216,6 +216,7 @@ app.use('/api/internal', internalRoutes); // requireInternalAuth); no Next proxy on purpose — never browser-facing. app.use('/api/partners', require('./routes/partners')); app.use('/api/founders', require('./routes/founders')); +app.use('/api/hero-prop', require('./routes/heroProp')); // Session 10 — Sentry's Express error handler catches uncaught // errors from every route mounted above. Must come AFTER routes but diff --git a/src/routes/heroProp.js b/src/routes/heroProp.js new file mode 100644 index 0000000..0c80a25 --- /dev/null +++ b/src/routes/heroProp.js @@ -0,0 +1,29 @@ +'use strict'; + +/** + * GET /api/hero-prop (Truth-Everywhere Part 2, item 5) — the daily hero prop: + * the live read where VYNDR disagrees most with the market (largest + * |model - consensus| gap, A/B only). Public, cache-only (reads the pre-graded + * snapshot) → never triggers grading, never spends API credits. Empty slate → + * most recent real graded read; nothing cached → { available:false } (hides). + */ + +const express = require('express'); +const { createRateLimit } = require('../middleware/rateLimit'); +const heroPropService = require('../services/heroPropService'); + +const router = express.Router(); +router.use(createRateLimit({ windowMs: 60_000, max: 60 })); + +router.get('/', async (req, res) => { + try { + const hero = await heroPropService.pickHeroProp({}); + res.set('Cache-Control', 'public, max-age=300'); + return res.json(hero); + } catch (err) { + console.error('[hero-prop]', err.message); + return res.status(200).json({ available: false }); + } +}); + +module.exports = router; diff --git a/src/services/heroPropService.js b/src/services/heroPropService.js new file mode 100644 index 0000000..4dfa108 --- /dev/null +++ b/src/services/heroPropService.js @@ -0,0 +1,93 @@ +'use strict'; + +/** + * heroPropService (Truth-Everywhere Part 2, item 5) — the DAILY HERO PROP. + * + * The landing card is a live rule, not a hand-picked example: the graded prop + * where VYNDR disagrees MOST with the market — the largest |model - consensus| + * gap — gated to A/B grades (conviction, not noise). Highest-confidence would + * just be the model agreeing loudly with an undisputed number; the disagreement + * is the read that makes a stranger argue. + * + * Deterministic, no curation. Reads the pre-graded snapshot caches (no grading, + * no API credits). Empty slate → the MOST RECENT real graded read with its real + * date. Never a hand-written fallback. Truly nothing cached → { available:false } + * and the card hides. + */ + +const DEFAULT_SPORTS = ['mlb', 'wnba', 'nba', 'soccer']; +// A/B tiers only — the conviction gate. +const isAB = (g) => /^[AB]/.test(String(g || '').trim().toUpperCase()); + +function toHero(g, sport, gap, isRecent) { + const at = g.gradedAt || {}; + return { + available: true, + is_recent: !!isRecent, // true = the empty-slate "most recent real read" + sport, + player: g.player_name || g.player || null, + stat_type: g.stat_type || g.stat || null, + line: g.line ?? at.line ?? null, // the book's number (consensus) + direction: g.direction || 'over', + projection: g.projection ?? null, // VYNDR's number (model) + grade: g.grade || null, + book: g.book || null, + graded_at: at.timestamp || null, // real timestamp — "graded 2:14 PM" + gap: gap == null ? null : Math.round(gap * 100) / 100, + team: g.team || null, + reasoning: (g.reasoning && g.reasoning.summary) || null, // blurred paywall teaser + }; +} + +// A prop is a valid hero candidate only with a real projection AND a real line +// (both required to compute an honest gap; a missing value never counts as 0). +function candidate(g) { + const line = Number(g && g.line); + const proj = Number(g && g.projection); + return Number.isFinite(line) && line > 0 && Number.isFinite(proj) && proj > 0; +} + +async function pickHeroProp(deps = {}) { + const cacheGet = deps.cacheGet || require('../utils/redis').cacheGet; + const sports = deps.sports || DEFAULT_SPORTS; + + const all = []; + for (const sport of sports) { + let grades = null; + const snap = await cacheGet(`snapshot:${sport}:latest`); + if (snap && Array.isArray(snap.grades)) grades = snap.grades; + else { + const env = await cacheGet(`grades:${sport}`); + if (env && Array.isArray(env.grades)) grades = env.grades; + } + for (const g of grades || []) { + if (!g || !g.grade || g.insufficient_data) continue; + all.push({ g, sport }); + } + } + + // The hero: largest |projection - line| among A/B candidates. + let hero = null, heroGap = -1; + for (const { g, sport } of all) { + if (!isAB(g.grade) || !candidate(g)) continue; + const gap = Math.abs(Number(g.projection) - Number(g.line)); + if (gap > heroGap) { heroGap = gap; hero = { g, sport }; } + } + if (hero) return toHero(hero.g, hero.sport, heroGap, false); + + // Empty slate → the MOST RECENT real graded read (any grade), by timestamp. + let recent = null, recentTs = ''; + for (const { g, sport } of all) { + const ts = (g.gradedAt && g.gradedAt.timestamp) || ''; + if (ts && ts > recentTs) { recentTs = ts; recent = { g, sport }; } + } + if (recent) { + const gp = candidate(recent.g) ? Math.abs(Number(recent.g.projection) - Number(recent.g.line)) : null; + return toHero(recent.g, recent.sport, gp, true); + } + + // Truly nothing cached — the card hides. Never a fabricated fallback. + return { available: false }; +} + +module.exports = { pickHeroProp, __internals: { isAB, candidate, DEFAULT_SPORTS } }; diff --git a/tests/unit/colorContract.test.js b/tests/unit/colorContract.test.js index 303ee41..0362673 100644 --- a/tests/unit/colorContract.test.js +++ b/tests/unit/colorContract.test.js @@ -136,11 +136,20 @@ describe('GradeBadge.tsx — glow gated to A-tier', () => { }); }); -describe('LiveHeroProp.tsx — edge tone is sign-driven', () => { +describe('LiveHeroProp.tsx — the disagreement display (item 5)', () => { + // The hero card now shows the book's line vs VYNDR's model side by side (the + // largest model-vs-market gap), not a signed edge %. Model = green (our + // number, the signal), book line = neutral. No static Jokic fallback. const src = read('components/LiveHeroProp.tsx'); - it('negative edge maps to muted red, positive to green', () => { - expect(src).toMatch(/edge > 0 \? 'positive' : edge < 0 \? 'negative'/); - expect(src).toContain("tone === 'negative' ? 'var(--miss)'"); + it('renders BOOK LINE vs VYNDR MODEL, model in green', () => { + expect(src).toContain('LINE'); // "{bookLabel} LINE" + expect(src).toContain('VYNDR MODEL'); + expect(src).toMatch(/VYNDR MODEL[^]*?color: 'var\(--grade-a\)'/); + }); + it('has no hand-written static fallback (no hardcoded Jokic example)', () => { + expect(src).not.toContain('Nikola Jokic'); + expect(src).not.toContain('aria-label="Example grade"'); // the old EXAMPLE chip is gone + expect(src).not.toContain('26.5'); // the old hardcoded line }); }); diff --git a/tests/unit/heroPropService.test.js b/tests/unit/heroPropService.test.js new file mode 100644 index 0000000..1aab5ef --- /dev/null +++ b/tests/unit/heroPropService.test.js @@ -0,0 +1,85 @@ +'use strict'; + +// Item 5 (Truth-Everywhere Part 2) — the daily hero prop is a deterministic +// live RULE: largest |projection - line| gap among A/B grades. Empty slate → +// most recent real read. Nothing → hidden. + +const { pickHeroProp, __internals } = require('../../src/services/heroPropService'); + +function cacheFrom(map) { + return async (key) => (key in map ? map[key] : null); +} +const grade = (o) => ({ + player_name: o.player, stat_type: o.stat, line: o.line, projection: o.proj, + direction: o.dir || 'over', grade: o.grade, book: o.book || 'dk', + gradedAt: { line: o.line, odds: -110, timestamp: o.ts || '2026-07-17T19:00:00Z' }, +}); + +describe('pickHeroProp', () => { + test('picks the LARGEST |projection - line| gap among A/B grades', async () => { + const cacheGet = cacheFrom({ + 'snapshot:mlb:latest': { grades: [ + grade({ player: 'Small Gap', stat: 'hits', line: 0.5, proj: 0.6, grade: 'A' }), // gap .1 + grade({ player: 'Big Gap', stat: 'strikeouts', line: 6.5, proj: 9.0, grade: 'B' }), // gap 2.5 + ] }, + }); + const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); + expect(hero.available).toBe(true); + expect(hero.player).toBe('Big Gap'); + expect(hero.gap).toBe(2.5); + expect(hero.is_recent).toBe(false); + expect(hero.graded_at).toBeTruthy(); // real timestamp + expect(hero.line).toBe(6.5); // the book number + expect(hero.projection).toBe(9.0); // the model number + }); + + test('C/D/F grades are NOT eligible (conviction gate)', async () => { + const cacheGet = cacheFrom({ + 'snapshot:mlb:latest': { grades: [ + grade({ player: 'Huge Gap C', stat: 'hits', line: 0.5, proj: 3.0, grade: 'C' }), // gap 2.5 but C + grade({ player: 'Real A', stat: 'hits', line: 0.5, proj: 0.9, grade: 'A' }), // gap .4 + ] }, + }); + const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); + expect(hero.player).toBe('Real A'); // the C is excluded despite a bigger gap + }); + + test('a prop with no projection or no line is not a candidate (never gap on 0)', async () => { + const cacheGet = cacheFrom({ + 'snapshot:mlb:latest': { grades: [ + { player_name: 'No Proj', stat_type: 'hits', line: 0.5, projection: 0, grade: 'A', gradedAt: { timestamp: '2026-07-17T19:00:00Z' } }, + grade({ player: 'Valid', stat: 'hits', line: 1.5, proj: 2.2, grade: 'B' }), + ] }, + }); + const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); + expect(hero.player).toBe('Valid'); + }); + + test('empty A/B slate → MOST RECENT real graded read (any grade), flagged', async () => { + const cacheGet = cacheFrom({ + 'snapshot:mlb:latest': { grades: [ + grade({ player: 'Older C', stat: 'hits', line: 0.5, proj: 0.4, grade: 'C', ts: '2026-07-17T14:00:00Z' }), + grade({ player: 'Newer C', stat: 'hits', line: 0.5, proj: 0.3, grade: 'C', ts: '2026-07-17T19:00:00Z' }), + ] }, + }); + const hero = await pickHeroProp({ cacheGet, sports: ['mlb'] }); + expect(hero.available).toBe(true); + expect(hero.is_recent).toBe(true); + expect(hero.player).toBe('Newer C'); // most recent by timestamp + }); + + test('nothing cached → { available:false } (card hides, no fabrication)', async () => { + const hero = await pickHeroProp({ cacheGet: cacheFrom({}), sports: ['mlb', 'nba'] }); + expect(hero).toEqual({ available: false }); + }); + + test('picks across sports (max gap wins regardless of sport)', async () => { + const cacheGet = cacheFrom({ + 'snapshot:mlb:latest': { grades: [grade({ player: 'MLB', stat: 'hits', line: 0.5, proj: 0.9, grade: 'A' })] }, // .4 + 'snapshot:wnba:latest': { grades: [grade({ player: 'WNBA', stat: 'points', line: 18.5, proj: 24.0, grade: 'B' })] }, // 5.5 + }); + const hero = await pickHeroProp({ cacheGet, sports: ['mlb', 'wnba'] }); + expect(hero.player).toBe('WNBA'); + expect(hero.sport).toBe('wnba'); + }); +}); diff --git a/web/src/app/api/hero-prop/route.ts b/web/src/app/api/hero-prop/route.ts index c16b22d..25dbc05 100644 --- a/web/src/app/api/hero-prop/route.ts +++ b/web/src/app/api/hero-prop/route.ts @@ -1,174 +1,30 @@ import { NextResponse } from 'next/server'; -// Session 17 — Next.js App Router refuses to compile a route that -// exports BOTH `dynamic = 'force-dynamic'` AND `revalidate`. The two -// modes are mutually exclusive: force-dynamic skips static -// generation; revalidate gates ISR. Session 16 shipped both, which -// silently broke the route at build time (production audit found a -// hard 404 on /api/hero-prop). -// -// We keep `force-dynamic` (we want the random-prop pick to vary per -// request) and emit the 15-minute cache via the response's -// Cache-Control header — Coolify's reverse proxy honors it. export const dynamic = 'force-dynamic'; const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; -const HERO_FETCH_TIMEOUT_MS = 6000; - -interface OddsResponseProp { - player: string; - stat_type: string; - line: number; - direction?: 'over' | 'under'; - book?: string; - home_team?: string; - away_team?: string; -} - -interface OddsResponse { - sport?: string; - source?: string; - props?: OddsResponseProp[]; - error?: string; -} - -interface GradeResponse { - grade?: string; - confidence?: number; - edge_pct?: number; - projection?: number; - reasoning?: { summary?: string; steps?: unknown }; - kill_conditions_triggered?: Array<{ code: string; reason: string }>; -} /** - * Live hero prop endpoint (Session 16). - * - * Picks one fresh, real prop from the day's odds and grades it. The - * landing page hero renders this in place of the static Jokic mockup - * — cold visitors see proof of live intelligence on first paint - * instead of a hypothetical example. - * - * Sport cascade: NBA → WNBA → MLB. Whichever sport produces a non- - * empty `props` list first wins. When every sport is empty (off- - * hours, holiday slate, upstream odds quota burned), responds with - * `{ isStatic: true }` and the client falls back to the existing - * static card. Never throws — odds outages must not blank the - * landing page. - * - * Two-stage flow: - * 1. GET ${BACKEND}/api/odds/{sport} → pick random prop - * 2. POST ${BACKEND}/api/analyze/prop → grade it - * - * Both calls share a 6s timeout (AbortController). The overall - * route is wrapped in try/catch and always 200s (with `isStatic:true` - * on failure) so the client renders gracefully. + * Hero-prop proxy (Truth-Everywhere Part 2, item 5) — forwards to the Express + * /api/hero-prop, which reads the pre-graded snapshot for the largest + * |model - consensus| gap among A/B grades (the read where VYNDR disagrees most + * with the market). Empty slate → most recent real graded read. Any failure → + * { available: false } so the card HIDES; there is NO hand-written fallback + * (the old static Jokic card is gone). */ -async function fetchWithTimeout(url: string, init?: RequestInit, ms = HERO_FETCH_TIMEOUT_MS): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ms); - try { - return await fetch(url, { ...init, signal: controller.signal }); - } catch { - return null; - } finally { - clearTimeout(timer); - } -} - -async function pickPropFromSport(sport: string): Promise<{ prop: OddsResponseProp; sport: string } | null> { - const res = await fetchWithTimeout(`${BACKEND_URL}/api/odds/${sport}`, { - method: 'GET', - headers: { Accept: 'application/json' }, - cache: 'no-store', - }); - if (!res || !res.ok) return null; - const body = (await res.json().catch(() => null)) as OddsResponse | null; - if (!body || !Array.isArray(body.props) || body.props.length === 0) return null; - - // Bias toward A-list player names — props with longer player names - // tend to be top-of-rotation stars (better hero material). Cheap - // heuristic, not a hard filter; we still random-pick among the top - // half of the sorted list so multiple page loads vary. - const sorted = body.props - .filter((p) => p.player && p.stat_type && Number.isFinite(p.line)) - .sort((a, b) => (b.player.length - a.player.length)); - if (sorted.length === 0) return null; - const topHalf = sorted.slice(0, Math.max(3, Math.ceil(sorted.length / 2))); - const pick = topHalf[Math.floor(Math.random() * topHalf.length)]; - return { prop: pick, sport }; -} - -async function gradeProp(sport: string, prop: OddsResponseProp): Promise { - const res = await fetchWithTimeout(`${BACKEND_URL}/api/analyze/prop`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - sport, - player: prop.player, - stat_type: prop.stat_type, - line: prop.line, - direction: prop.direction || 'over', - book: prop.book || 'draftkings', - }), - cache: 'no-store', - }); - if (!res || !res.ok) return null; - return (await res.json().catch(() => null)) as GradeResponse | null; -} - export async function GET() { - // The order matters: NBA props lead because mid-summer the cascade - // would otherwise constantly land on the same sport. After NBA - // off-season concludes, swap to a season-aware ordering (winter: - // NBA, summer: MLB + WNBA, fall: NFL — when supported). - const sportsToTry = ['nba', 'wnba', 'mlb']; - try { - for (const sport of sportsToTry) { - const picked = await pickPropFromSport(sport); - if (!picked) continue; - const grade = await gradeProp(picked.sport, picked.prop); - if (!grade) continue; - return NextResponse.json( - { - isStatic: false, - sport: picked.sport, - prop: picked.prop, - grade, - }, - { headers: { 'Cache-Control': 'public, s-maxage=900, stale-while-revalidate=60' } }, - ); - } + const upstream = await fetch(`${BACKEND_URL}/api/hero-prop`, { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + const data = await upstream.json().catch(() => ({ available: false })); + return NextResponse.json(data, { + status: 200, + headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' }, + }); } catch { - // Falls through to static fallback below. + return NextResponse.json({ available: false }, { status: 200 }); } - - // Static fallback — keeps the hero alive when every sport is empty. - return NextResponse.json( - { - isStatic: true, - sport: 'nba', - prop: { - player: 'Nikola Jokic', - stat_type: 'points', - line: 26.5, - direction: 'over', - book: 'draftkings', - home_team: 'DEN', - away_team: 'LAL', - }, - grade: { - grade: 'A-', - confidence: 73, - edge_pct: 6.2, - projection: 29.4, - reasoning: { - summary: 'L5 form is 28.6 over 5 games, +2.1 above the line. Lakers are bottom-five vs Cs.', - }, - kill_conditions_triggered: [], - }, - }, - { headers: { 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60' } }, - ); } diff --git a/web/src/components/Hero.tsx b/web/src/components/Hero.tsx index 013ccf1..1f37bbc 100644 --- a/web/src/components/Hero.tsx +++ b/web/src/components/Hero.tsx @@ -1,9 +1,9 @@ 'use client'; -// Session 16 — the floating demo card on the right side of the hero -// is now driven by /api/hero-prop. Live prop + grade renders with a -// glitch/blur overlay on the reasoning. Falls back to the static -// Jokic layout when no live odds are available. +// Session 16 — the floating card on the right side of the hero is driven by +// /api/hero-prop: the live daily hero prop (largest model-vs-market +// disagreement, A/B only). Truth-Everywhere Part 2 (item 5) removed the static +// Jokic fallback — the card renders a real graded read or hides. import LiveHeroProp from './LiveHeroProp'; export default function Hero() { @@ -179,8 +179,7 @@ function SportBadgeStrip() { // Session 16 — FloatingDemoCard / Stat / row removed. The hero card // is now a live, graded prop fetched on mount; see LiveHeroProp.tsx. -// The static Jokic layout lives ONCE inside that component as the -// cold-start fallback when /api/hero-prop returns isStatic:true. +// It has NO static fallback (item 5) — a real read or nothing. // // GradePill (re-exported by GradeCard) is still imported at the top // of this file because the section header uses it elsewhere; if a diff --git a/web/src/components/LiveHeroProp.tsx b/web/src/components/LiveHeroProp.tsx index 44a305f..0fdd838 100644 --- a/web/src/components/LiveHeroProp.tsx +++ b/web/src/components/LiveHeroProp.tsx @@ -4,343 +4,156 @@ import { useEffect, useState } from 'react'; import { GradePill } from './GradeCard'; /** - * Live hero prop card (Session 16). + * Daily hero prop card (Truth-Everywhere Part 2, item 5). * - * Replaces the static Jokic mockup. Fetches /api/hero-prop on mount, - * renders the resulting graded prop, applies a glitch/blur overlay - * on the reasoning section so the grade letter + projection + edge - * are crisp (the hook) but the supporting analysis stays behind a - * paywall (the convert). - * - * Two states: - * - Loading or `isStatic === true` from the API → render the - * existing static layout (kept identical for visual stability - * across the cold-start path). - * - Live prop returned → render real data with the glitch overlay. - * - * Glitch overlay: backdrop-filter blur(4px) + a scan-line gradient - * pseudo-element. CSS keyframes in globals.css ensure mobile gets a - * slower, less-CPU-hungry version (the gradient is static there). + * A live RULE, not a hand-picked example: the graded prop where VYNDR disagrees + * MOST with the market — largest |model - consensus| gap, A/B grades only — + * fetched from /api/hero-prop (which reads the pre-graded snapshot). The card + * shows the disagreement explicitly (the book's number vs ours) with the real + * grade timestamp. Empty slate → the most recent real graded read. Nothing + * cached → the card hides. There is NO static fallback — the old Jokic + * "Example" card is gone. */ -type HeroPropApi = { - isStatic?: boolean; +type Hero = { + available: boolean; + is_recent?: boolean; sport?: string; - prop?: { - player: string; - stat_type: string; - line: number; - direction?: 'over' | 'under'; - book?: string; - home_team?: string; - away_team?: string; - }; - grade?: { - grade?: string; - confidence?: number; - edge_pct?: number; - projection?: number; - reasoning?: { summary?: string }; - kill_conditions_triggered?: Array<{ code: string; reason: string }>; - }; + player?: string | null; + stat_type?: string | null; + line?: number | null; + direction?: 'over' | 'under'; + projection?: number | null; + grade?: string | null; + book?: string | null; + graded_at?: string | null; + reasoning?: string | null; }; -const SPORT_LABEL: Record = { - nba: 'NBA', - wnba: 'WNBA', - mlb: 'MLB', - soccer_wc: 'World Cup', +const SPORT_LABEL: Record = { nba: 'NBA', wnba: 'WNBA', mlb: 'MLB', soccer: 'Soccer' }; +const SPORT_COLOR: Record = { nba: '#E94B3C', wnba: '#FFB347', mlb: '#1E90FF', soccer: '#00D4A0' }; +const BOOK_LABEL: Record = { + draftkings: 'DK', fanduel: 'FD', betmgm: 'MGM', caesars: 'CZR', pointsbet: 'PB', betrivers: 'BR', }; -const SPORT_COLOR: Record = { - nba: '#E94B3C', - wnba: '#FFB347', - mlb: '#1E90FF', - soccer_wc: '#00D4A0', -}; - -const row: React.CSSProperties = { - display: 'flex', - justifyContent: 'space-between', - paddingBlock: 4, - borderBottom: '1px solid var(--border)', -}; - -function Stat({ label, value, tone }: { label: string; value: string; tone?: 'positive' | 'negative' }) { - return ( -
-
{label}
-
- {value} -
-
- ); +function fmtTime(iso?: string | null, withDate = false): string { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + const t = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); + if (!withDate) return t; + return `${d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} · ${t}`; } export default function LiveHeroProp() { - const [data, setData] = useState(null); + const [data, setData] = useState(null); const [loaded, setLoaded] = useState(false); useEffect(() => { let alive = true; fetch('/api/hero-prop', { cache: 'no-store' }) .then((r) => (r.ok ? r.json() : null)) - .then((json) => { - if (!alive) return; - setData(json); - setLoaded(true); - }) - .catch(() => { - if (!alive) return; - setLoaded(true); - }); + .then((json) => { if (alive) { setData(json); setLoaded(true); } }) + .catch(() => { if (alive) setLoaded(true); }); return () => { alive = false; }; }, []); - // While loading OR if the API returned the static fallback, render - // the deterministic Jokic layout. Visual continuity matters here — - // cold visitors should see SOMETHING on first paint, then the - // live data slots in once /api/hero-prop returns. - const isLive = loaded && data && !data.isStatic && data.prop && data.grade; + // No fabrication: hide until a REAL read is available. + if (!loaded || !data || !data.available || !data.player) return null; - // Pull display fields with safe fallbacks. - const prop = data?.prop; - const grade = data?.grade; - const sport = data?.sport || 'nba'; - const matchupLabel = prop?.home_team && prop?.away_team - ? `${prop.away_team} @ ${prop.home_team}` - : '—'; - const statTypeLabel = (prop?.stat_type || 'points').replace(/_/g, ' '); - const lineDisplay = prop ? `${(prop.direction || 'over').charAt(0).toUpperCase() + (prop.direction || 'over').slice(1)} ${prop.line} ${statTypeLabel}` : ''; - - // Static-fallback view (the original Jokic card, byte-for-byte - // visually). We render this until the live API returns, then swap. - if (!isLive) { - return ( -
- {/* Session 25 — the static fallback is an ILLUSTRATIVE example, not - a live pick. Labelling it prevents the fixed stats from reading - as stale real data when no live hero-prop is flowing. */} - - Example - -
-
- - NBA - -

Nikola Jokic

-

- Over 26.5 points -

-
- -
-
- - -
-
    -
  • MatchupLAL · 26th vs C
  • -
  • L10 form27.4 / 7 of 10
  • -
  • Usage shift+3.2% w/o Murray
  • -
-
- ); - } - - // Live render. - const gradeText = grade?.grade || 'C'; - const confidence = typeof grade?.confidence === 'number' ? Math.round(grade.confidence) : 50; - const projection = typeof grade?.projection === 'number' ? grade.projection.toFixed(1) : '—'; - const edge = typeof grade?.edge_pct === 'number' ? grade.edge_pct : 0; - const edgeDisplay = `${edge >= 0 ? '+' : ''}${edge.toFixed(1)}%`; - const reasoning = grade?.reasoning?.summary || ''; + const sport = data.sport || 'mlb'; const sportLabel = SPORT_LABEL[sport] || sport.toUpperCase(); const sportColor = SPORT_COLOR[sport] || 'var(--grade-a)'; + const statLabel = (data.stat_type || '').replace(/_/g, ' '); + const dir = (data.direction || 'over'); + const dirCap = dir.charAt(0).toUpperCase() + dir.slice(1); + const bookLabel = data.book ? (BOOK_LABEL[data.book] || data.book.toUpperCase()) : 'BOOK'; + const grade = data.grade || 'B'; + const line = typeof data.line === 'number' ? data.line : null; + const projection = typeof data.projection === 'number' ? data.projection : null; + const isRecent = !!data.is_recent; return (
- {/* LIVE badge — pulsing dot communicates "this was graded just now". */} + {/* Status — LIVE for a current read; LATEST for the empty-slate fallback. + Never "Example": this is a real graded read either way. */}
- - LIVE + {!isRecent && ( + + )} + {isRecent ? 'LATEST READ' : 'LIVE'}
- {/* Header — visible, the hook. */} -
+ {/* Header */} +
- + {sportLabel} -

{prop!.player}

+

{data.player}

- {lineDisplay} + {dirCap} {line ?? ''} {statLabel}

- +
- {/* Projection + edge — visible, the proof. */} -
- - 0 ? 'positive' : edge < 0 ? 'negative' : undefined} /> -
- - {/* Reasoning — BLURRED, the paywall. */} -
-
- {reasoning || 'Recent form: 28.4 over last 5. Opp defense: top-5 vs PG. Pace: +3.1. Trap composite 0.18. Usage 31%. Kill conditions: 0.'} + {/* THE DISAGREEMENT — the book's number vs ours, side by side. This is the + whole point: the read where VYNDR disagrees most with the market. */} +
+
+
{bookLabel} LINE
+
+ {line ?? '—'} +
- {/* Scan-line overlay — pure CSS gradient pseudo-element via - inline style + position absolute. Subtle on desktop, - disabled on mobile by the @media query below. */} -