Item 5 — daily hero prop is a live RULE (biggest model-vs-market disagreement)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 } };
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<Response | null> {
|
||||
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<GradeResponse | null> {
|
||||
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' } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+102
-289
@@ -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<string, string> = {
|
||||
nba: 'NBA',
|
||||
wnba: 'WNBA',
|
||||
mlb: 'MLB',
|
||||
soccer_wc: 'World Cup',
|
||||
const SPORT_LABEL: Record<string, string> = { nba: 'NBA', wnba: 'WNBA', mlb: 'MLB', soccer: 'Soccer' };
|
||||
const SPORT_COLOR: Record<string, string> = { nba: '#E94B3C', wnba: '#FFB347', mlb: '#1E90FF', soccer: '#00D4A0' };
|
||||
const BOOK_LABEL: Record<string, string> = {
|
||||
draftkings: 'DK', fanduel: 'FD', betmgm: 'MGM', caesars: 'CZR', pointsbet: 'PB', betrivers: 'BR',
|
||||
};
|
||||
|
||||
const SPORT_COLOR: Record<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: 'var(--bg-surface)',
|
||||
borderRadius: 10,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{label}</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
// Color contract #2: edge/CLV colored by SIGN — positive green, negative muted red.
|
||||
color: tone === 'positive' ? 'var(--grade-a)' : tone === 'negative' ? 'var(--miss)' : 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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<HeroPropApi | null>(null);
|
||||
const [data, setData] = useState<Hero | null>(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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
transform: 'rotate(-1deg)',
|
||||
padding: 24,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-focus)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
|
||||
maxWidth: 380,
|
||||
marginInline: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 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. */}
|
||||
<span
|
||||
className="mono"
|
||||
aria-label="Example grade"
|
||||
style={{
|
||||
position: 'absolute', top: 12, right: 12, fontSize: 9, fontWeight: 800,
|
||||
letterSpacing: '0.12em', padding: '3px 7px', borderRadius: 4,
|
||||
background: 'rgba(255,255,255,0.06)', color: 'var(--text-tertiary)', textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
Example
|
||||
</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 999,
|
||||
background: 'rgba(233,75,60,0.15)', color: '#E94B3C', fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
NBA
|
||||
</span>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>Nikola Jokic</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
Over 26.5 points
|
||||
</p>
|
||||
</div>
|
||||
<GradePill grade="A-" confidence={73} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<Stat label="Projection" value="29.4 pts" />
|
||||
<Stat label="Edge" value="+6.2%" tone="positive" />
|
||||
</div>
|
||||
<ul style={{ display: 'grid', gap: 6, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
<li style={row}><span>Matchup</span><span style={{ color: 'var(--text-primary)' }}>LAL · 26th vs C</span></li>
|
||||
<li style={row}><span>L10 form</span><span style={{ color: 'var(--text-primary)' }}>27.4 / 7 of 10</span></li>
|
||||
<li style={row}><span>Usage shift</span><span style={{ color: 'var(--grade-a)' }}>+3.2% w/o Murray</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
transform: 'rotate(-1deg)',
|
||||
padding: 24,
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-focus)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
|
||||
maxWidth: 380,
|
||||
marginInline: 'auto',
|
||||
position: 'relative', transform: 'rotate(-1deg)', padding: 24,
|
||||
background: 'var(--bg-elevated)', border: '1px solid var(--border-focus)',
|
||||
borderRadius: 20, boxShadow: '0 24px 64px rgba(0,0,0,0.6), 0 0 0 1px var(--accent-glow)',
|
||||
maxWidth: 380, marginInline: 'auto',
|
||||
}}
|
||||
>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className="mono"
|
||||
aria-label="Live"
|
||||
aria-label={isRecent ? 'Most recent read' : 'Live'}
|
||||
style={{
|
||||
position: 'absolute', top: 12, right: 12,
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 9, color: 'var(--grade-a)', fontWeight: 800, letterSpacing: '0.1em',
|
||||
position: 'absolute', top: 12, right: 12, display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 9, color: isRecent ? 'var(--text-tertiary)' : 'var(--grade-a)', fontWeight: 800, letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: 'var(--grade-a)',
|
||||
boxShadow: '0 0 8px var(--grade-a)',
|
||||
}}
|
||||
/>
|
||||
LIVE
|
||||
{!isRecent && (
|
||||
<span aria-hidden style={{ width: 6, height: 6, borderRadius: '50%', background: 'var(--grade-a)', boxShadow: '0 0 8px var(--grade-a)' }} />
|
||||
)}
|
||||
{isRecent ? 'LATEST READ' : 'LIVE'}
|
||||
</div>
|
||||
|
||||
{/* Header — visible, the hook. */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, marginTop: 8 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 14, marginTop: 8 }}>
|
||||
<div>
|
||||
<span
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 11, padding: '2px 8px', borderRadius: 999,
|
||||
background: `${sportColor}26`, color: sportColor, fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ fontSize: 11, padding: '2px 8px', borderRadius: 999, background: `${sportColor}26`, color: sportColor, fontWeight: 700 }}>
|
||||
{sportLabel}
|
||||
</span>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>{prop!.player}</h3>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginTop: 8 }}>{data.player}</h3>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{lineDisplay}
|
||||
{dirCap} {line ?? ''} {statLabel}
|
||||
</p>
|
||||
</div>
|
||||
<GradePill grade={gradeText} confidence={confidence} />
|
||||
<GradePill grade={grade} />
|
||||
</div>
|
||||
|
||||
{/* Projection + edge — visible, the proof. */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||||
<Stat label="Projection" value={`${projection} ${statTypeLabel}`} />
|
||||
<Stat label="Edge" value={edgeDisplay} tone={edge > 0 ? 'positive' : edge < 0 ? 'negative' : undefined} />
|
||||
</div>
|
||||
|
||||
{/* Reasoning — BLURRED, the paywall. */}
|
||||
<div
|
||||
className="hero-grade-reasoning"
|
||||
aria-label="Full analysis available after sign up"
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: 12,
|
||||
background: 'var(--bg-surface)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
filter: 'blur(4px)',
|
||||
userSelect: 'none',
|
||||
pointerEvents: 'none',
|
||||
fontSize: 12,
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{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. */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
|
||||
<div style={{ flex: 1, padding: '8px 12px', background: 'var(--bg-surface)', borderRadius: 10, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--text-tertiary)' }}>{bookLabel} LINE</div>
|
||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{line ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
{/* Scan-line overlay — pure CSS gradient pseudo-element via
|
||||
inline style + position absolute. Subtle on desktop,
|
||||
disabled on mobile by the @media query below. */}
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
backgroundImage: 'repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,212,160,0.06) 2px, rgba(0,212,160,0.06) 4px)',
|
||||
pointerEvents: 'none',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'linear-gradient(180deg, rgba(18,18,26,0) 30%, rgba(18,18,26,0.85) 100%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 6,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
color: 'var(--text-tertiary)',
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
className="mono"
|
||||
>
|
||||
Classified · Sign up to unlock
|
||||
<div style={{ flex: 1, padding: '8px 12px', background: 'var(--bg-surface)', borderRadius: 10, textAlign: 'center', border: '1px solid var(--accent-glow)' }}>
|
||||
<div style={{ fontSize: 10, color: 'var(--grade-a)' }}>VYNDR MODEL</div>
|
||||
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: 'var(--grade-a)' }}>
|
||||
{projection != null ? projection.toFixed(projection < 10 ? 1 : 0) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA — drives the conversion the blur sets up. */}
|
||||
<a
|
||||
href="/signup"
|
||||
className="btn-primary"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
padding: '10px 16px',
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Real timestamp — proves this was graded, and when. */}
|
||||
{data.graded_at && (
|
||||
<p className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary)', margin: '0 0 12px', letterSpacing: '0.04em' }}>
|
||||
Graded {fmtTime(data.graded_at, isRecent)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Reasoning — BLURRED, the paywall (only when the model supplied one). */}
|
||||
{data.reasoning && (
|
||||
<div
|
||||
className="hero-grade-reasoning"
|
||||
aria-label="Full analysis available after sign up"
|
||||
style={{ position: 'relative', padding: 12, background: 'var(--bg-surface)', borderRadius: 10, marginBottom: 12, overflow: 'hidden' }}
|
||||
>
|
||||
<div style={{ filter: 'blur(4px)', userSelect: 'none', pointerEvents: 'none', fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.4 }} aria-hidden>
|
||||
{data.reasoning}
|
||||
</div>
|
||||
<div aria-hidden style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(18,18,26,0) 30%, rgba(18,18,26,0.85) 100%)', pointerEvents: 'none' }} />
|
||||
<div className="mono" style={{ position: 'absolute', bottom: 6, left: 0, right: 0, textAlign: 'center', fontSize: 10, color: 'var(--text-tertiary)', letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||||
Classified · Sign up to unlock
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a href="/signup" className="btn-primary" style={{ display: 'block', textAlign: 'center', padding: '10px 16px', fontSize: 13, fontWeight: 700, textDecoration: 'none' }}>
|
||||
Sign up to read the full analysis →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user