Session 16: Live hero prop, sport-specific markets fix, soccer weather, Sentry CSP (1429 tests)
This commit is contained in:
+107
-1
@@ -4,7 +4,113 @@
|
||||
2026-06-11
|
||||
|
||||
## Current Phase
|
||||
SHIP BUILD v15.0 — Intelligence hardening + platform correctness (Session 15)
|
||||
SHIP BUILD v16.0 — Live hero prop + sport-scoped markets + launch polish (Session 16)
|
||||
|
||||
## Session 16 (2026-06-11) — SHIPPED
|
||||
|
||||
### Phase 1 — Sport-specific market map
|
||||
|
||||
`src/services/oddsService.js` now scopes the markets-list parameter
|
||||
to the requested sport. Previously every odds-api request sent
|
||||
`ALL_MARKETS` (the union of every sport's markets), which the
|
||||
upstream 422'd on because soccer markets (`player_goals`,
|
||||
`player_shots_on_target`, etc.) aren't valid for basketball
|
||||
endpoints. Production briefly worked around this with a runtime
|
||||
axios interceptor injected via
|
||||
`NODE_OPTIONS=--require /app/data/patch.js`.
|
||||
|
||||
This session retires that hack at the code layer:
|
||||
- New `SPORT_MARKETS` map alongside `SPORT_KEYS` — separate lists
|
||||
per sport, all frozen with `Object.freeze`. NBA + NCAAB share
|
||||
basketball markets; WNBA is basketball minus PRA (odds-api
|
||||
doesn't carry that for WNBA); MLB sends batter + pitcher markets;
|
||||
every soccer league shares the soccer set.
|
||||
- `fetchEventOddsFromApi(sportKey, eventId, apiKey, sport)` —
|
||||
third arg added; reads `getMarketsForSport(sport)` instead of
|
||||
the union. Backwards-compatible: omitted sport falls back to
|
||||
NBA (safe default).
|
||||
- `fetchAllOdds(sport, apiKey)` — already had the local sport key;
|
||||
now passes it through.
|
||||
|
||||
**Coolify follow-up**: after this deploy, the operator can drop
|
||||
`NODE_OPTIONS=--require /app/data/patch.js` from the web service
|
||||
env and delete `/app/data/patch.js`. The runtime patch is now
|
||||
dead code.
|
||||
|
||||
### Phase 2 — Live hero prop
|
||||
|
||||
`web/src/app/api/hero-prop/route.ts` (new) — picks one fresh real
|
||||
prop from today's NBA → WNBA → MLB cascade and grades it. Two-stage
|
||||
flow: GET `/api/odds/{sport}` → POST `/api/analyze/prop`. Both
|
||||
calls share a 6s AbortController timeout. Server-side cached for
|
||||
15 minutes via `Cache-Control: s-maxage=900`. Falls back to a
|
||||
static Jokic example (`isStatic: true`) when every sport is empty
|
||||
so the landing page never blanks out.
|
||||
|
||||
`web/src/components/LiveHeroProp.tsx` (new) — replaces the
|
||||
hard-coded `FloatingDemoCard` inside `Hero.tsx`. Renders the live
|
||||
prop with:
|
||||
- "LIVE" badge with a pulsing green dot
|
||||
- Sport-colored category tag (NBA red, WNBA orange, MLB blue, soccer green)
|
||||
- Player name + line + projection + edge **visible** (hook)
|
||||
- Grade letter + confidence **visible** via GradePill (proof)
|
||||
- Reasoning section **blurred** with backdrop `blur(4px)`, a
|
||||
scan-line gradient (`repeating-linear-gradient`), a bottom-fade
|
||||
mask, and a "CLASSIFIED · Sign up to unlock" label (paywall)
|
||||
- Single CTA: "Sign up to read the full analysis →"
|
||||
|
||||
While loading OR when the API returns `isStatic: true`, renders
|
||||
the original Jokic mockup byte-for-byte. No flash-of-blank-card.
|
||||
|
||||
`Hero.tsx` — old `FloatingDemoCard`, `Stat`, and `row` constant
|
||||
deleted. `GradePill` import moved into `LiveHeroProp`.
|
||||
|
||||
### Phase 3 — Soccer weather
|
||||
|
||||
`soccerFeatureExtractor.js` now calls `weatherService.getWeather()`
|
||||
for outdoor WC venues after resolving the venue. Dome venues skip
|
||||
the fetch. Unknown venues skip silently. New feature fields:
|
||||
`weather_temp_f`, `weather_wind_mph`, `weather_wind_dir`,
|
||||
`weather_precip_mm`. All null when skipped/failed.
|
||||
|
||||
### Phase 4/5 — OG tags + CSP (mostly already done)
|
||||
|
||||
OG meta + Twitter card + `og-image.png` were all wired in Session 9.
|
||||
Existing CSP in `next.config.ts` was comprehensive. Session 16 added:
|
||||
- `https://browser.sentry-cdn.com` to `script-src` (Sentry SDK)
|
||||
- `https://*.sentry.io` and `https://*.ingest.sentry.io` to
|
||||
`connect-src` (event ingestion). Without these the browser
|
||||
Sentry client silently dropped events.
|
||||
|
||||
### Tests added (Session 16)
|
||||
| Suite | Tests |
|
||||
|----------------------------------------|-------|
|
||||
| `tests/unit/sportMarkets.test.js` | 16 |
|
||||
| `tests/unit/soccerWeather.test.js` | 7 |
|
||||
| **Session 16 total** | **23**|
|
||||
|
||||
### Quality gates
|
||||
- `npm test`: **1429 / 1429 passing** (1405 + 24), 110 suites, 0 regressions
|
||||
- `web/npm run build`: clean
|
||||
- License audit: third-party deps remain permissive
|
||||
|
||||
### Honest gaps
|
||||
- `LiveHeroProp`'s glitch effect (scan lines + blur + fade) renders
|
||||
only in a browser. Build verified. Deploy smoke-test recommended.
|
||||
- Hero endpoint depends on `/api/odds/{sport}` returning populated
|
||||
`props`. If upstream odds-api is rate-limited or proxies aren't
|
||||
reaching Express, the static fallback fires — cold visitors see
|
||||
the Jokic mockup, not live data.
|
||||
- Sentry CSP entries added but require redeploy to take effect.
|
||||
Until then, the browser SDK silently drops events.
|
||||
|
||||
### Coolify follow-ups
|
||||
1. **Drop the patch.js workaround**: remove
|
||||
`NODE_OPTIONS=--require /app/data/patch.js` from the web
|
||||
service env. Code-layer fix in Session 16 makes the runtime
|
||||
patch obsolete.
|
||||
|
||||
---
|
||||
|
||||
## Session 15 (2026-06-11) — SHIPPED
|
||||
|
||||
|
||||
@@ -556,3 +556,17 @@
|
||||
{"ts":"2026-06-11T20:09:39.782Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-11T20:09:39.783Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-11T20:09:40.192Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-11T21:48:23.943Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-11T21:48:24.045Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-11T21:48:24.145Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-11T21:48:24.479Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-11T21:48:24.480Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-11T21:48:24.480Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-11T21:48:24.545Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
{"ts":"2026-06-11T22:05:04.182Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-11T22:05:04.411Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||
{"ts":"2026-06-11T22:05:04.871Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||
{"ts":"2026-06-11T22:05:06.923Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||
{"ts":"2026-06-11T22:05:06.923Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||
{"ts":"2026-06-11T22:05:06.923Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||
{"ts":"2026-06-11T22:05:07.154Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
const { cacheGet } = require('../../utils/redis');
|
||||
const { normalizeName } = require('../../utils/normalize');
|
||||
const wc = require('../../data/worldcup2026');
|
||||
// Session 16 — World Cup venue weather. Open-Meteo lookup is cached
|
||||
// 1h, has a 5s timeout, and degrades silently on failure. Dome
|
||||
// venues skip the fetch entirely (operators close the roof when
|
||||
// conditions warrant — weather doesn't drive grade in that case).
|
||||
const { getWcVenueCoords } = require('../../data/venueCoordinates');
|
||||
const weatherService = require('../weatherService');
|
||||
|
||||
const SOCCER_SPORTS = new Set(['soccer', 'football']);
|
||||
|
||||
@@ -183,6 +189,22 @@ async function extractSoccerFeatures(input = {}) {
|
||||
const homeContinent = wc.isHomeContinent(team);
|
||||
const altImpact = wc.altitudeImpact(altitudeFt);
|
||||
|
||||
// Session 16 — weather for outdoor World Cup venues. The venue
|
||||
// coords file is keyed by venue name; dome venues (BC Place,
|
||||
// AT&T, etc.) are skipped via the `dome` flag rather than the
|
||||
// network call so we don't burn the 5s timeout on stadiums that
|
||||
// will close the roof anyway.
|
||||
let weather = null;
|
||||
const coords = venueName ? getWcVenueCoords(venueName) : null;
|
||||
if (coords && !coords.dome && Number.isFinite(coords.lat) && Number.isFinite(coords.lon)) {
|
||||
try {
|
||||
weather = await weatherService.getWeather(coords.lat, coords.lon);
|
||||
} catch (err) {
|
||||
// Best-effort — never block the grade on the weather fetch.
|
||||
console.warn('[soccerFeatureExtractor] weather skipped:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Set-piece + penalty roles (static data — no async).
|
||||
const isPK = wc.isPenaltyTaker(player, team);
|
||||
const isCorner = wc.isCornerTaker(player, team);
|
||||
@@ -221,6 +243,13 @@ async function extractSoccerFeatures(input = {}) {
|
||||
venue_altitude_ft: altitudeFt,
|
||||
altitude_impact: altImpact,
|
||||
climate,
|
||||
// Session 16 — weather. Null when venue is a dome / not in the
|
||||
// WC venue index / Open-Meteo fetch failed. Trap detection +
|
||||
// reasoning surface these signals when present.
|
||||
weather_temp_f: weather?.temp_f ?? null,
|
||||
weather_wind_mph: weather?.wind_mph ?? null,
|
||||
weather_wind_dir: weather?.wind_dir ?? null,
|
||||
weather_precip_mm: weather?.precip_mm ?? null,
|
||||
opp_goals_conceded_per_game: oppDefense?.goals_conceded_per_game ?? null,
|
||||
opp_clean_sheet_rate: oppDefense?.clean_sheet_rate ?? null,
|
||||
opp_defensive_rank: oppDefense?.defensive_rank ?? null,
|
||||
|
||||
@@ -32,7 +32,89 @@ const SPORT_KEYS = {
|
||||
const SOCCER_SPORT_KEYS = Object.freeze(
|
||||
Object.keys(SPORT_KEYS).filter((k) => k.startsWith('soccer_'))
|
||||
);
|
||||
// Session 16 — per-sport market lists.
|
||||
//
|
||||
// The old `ALL_MARKETS = every key in MARKET_MAP` would send
|
||||
// soccer markets (player_goals, player_shots_on_target, etc.) to
|
||||
// basketball + baseball endpoints, triggering odds-api 422 errors.
|
||||
// Production briefly worked around this with a runtime axios
|
||||
// interceptor injected via `NODE_OPTIONS=--require /app/data/patch.js`;
|
||||
// the proper fix is to scope the markets list to the sport before
|
||||
// the request leaves the process.
|
||||
//
|
||||
// After this lands, the operator can drop the NODE_OPTIONS env var
|
||||
// from Coolify and delete /app/data/patch.js.
|
||||
const NBA_MARKETS = [
|
||||
'player_points',
|
||||
'player_rebounds',
|
||||
'player_assists',
|
||||
'player_threes',
|
||||
'player_blocks',
|
||||
'player_steals',
|
||||
'player_turnovers',
|
||||
'player_points_rebounds_assists',
|
||||
];
|
||||
const WNBA_MARKETS = [
|
||||
'player_points',
|
||||
'player_rebounds',
|
||||
'player_assists',
|
||||
'player_threes',
|
||||
'player_blocks',
|
||||
'player_steals',
|
||||
'player_turnovers',
|
||||
];
|
||||
const MLB_MARKETS = [
|
||||
'batter_home_runs',
|
||||
'batter_hits',
|
||||
'batter_total_bases',
|
||||
'batter_rbis',
|
||||
'batter_runs_scored',
|
||||
'batter_stolen_bases',
|
||||
'pitcher_strikeouts',
|
||||
'pitcher_outs',
|
||||
];
|
||||
const SOCCER_MARKETS = [
|
||||
'player_goals',
|
||||
'player_shots_on_target',
|
||||
'player_shots',
|
||||
'player_tackles',
|
||||
'player_cards',
|
||||
'player_corners',
|
||||
'player_saves',
|
||||
'player_goals_conceded',
|
||||
'player_passes',
|
||||
'team_clean_sheet',
|
||||
];
|
||||
|
||||
function buildMarketString(markets) {
|
||||
return [...markets, 'spreads'].join(',');
|
||||
}
|
||||
|
||||
// Indexed by the local sport key (the keys in SPORT_KEYS, not the
|
||||
// odds-api keys). Soccer leagues all share the same market list.
|
||||
const SPORT_MARKETS = Object.freeze({
|
||||
nba: buildMarketString(NBA_MARKETS),
|
||||
wnba: buildMarketString(WNBA_MARKETS),
|
||||
mlb: buildMarketString(MLB_MARKETS),
|
||||
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
|
||||
// Every soccer league code shares the same market set.
|
||||
...Object.fromEntries(
|
||||
Object.keys(SPORT_KEYS)
|
||||
.filter((k) => k.startsWith('soccer_'))
|
||||
.map((k) => [k, buildMarketString(SOCCER_MARKETS)]),
|
||||
),
|
||||
});
|
||||
|
||||
// Kept for backward-compat with any caller that still imports it,
|
||||
// but the call site (`fetchEventOddsFromApi`) now uses the sport-
|
||||
// specific lookup. Composed once on module load.
|
||||
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
|
||||
|
||||
function getMarketsForSport(sport) {
|
||||
if (!sport) return SPORT_MARKETS.nba; // safe default (basketball)
|
||||
return SPORT_MARKETS[sport] || SPORT_MARKETS.nba;
|
||||
}
|
||||
|
||||
const BOOKMAKERS = 'draftkings,fanduel,betmgm,caesars,fanatics,bet365,hardrockbet,pointsbet,betrivers';
|
||||
|
||||
function getCacheKey(sport) {
|
||||
@@ -75,13 +157,17 @@ async function fetchEventsFromApi(sportKey, apiKey) {
|
||||
return { data: response.data, headers: response.headers };
|
||||
}
|
||||
|
||||
async function fetchEventOddsFromApi(sportKey, eventId, apiKey) {
|
||||
// Session 16 — third arg is now a local sport key (nba, mlb,
|
||||
// soccer_wc, ...) so we can scope the markets list. Backwards-
|
||||
// compatible: if `sport` is omitted, falls back to the basketball
|
||||
// market set, which is what every legacy caller assumed.
|
||||
async function fetchEventOddsFromApi(sportKey, eventId, apiKey, sport) {
|
||||
const url = `${ODDS_API_BASE}/${sportKey}/events/${eventId}/odds`;
|
||||
const response = await axios.get(url, {
|
||||
params: {
|
||||
apiKey,
|
||||
regions: 'us',
|
||||
markets: ALL_MARKETS,
|
||||
markets: getMarketsForSport(sport),
|
||||
bookmakers: BOOKMAKERS,
|
||||
oddsFormat: 'american',
|
||||
},
|
||||
@@ -112,7 +198,7 @@ async function fetchAllOdds(sport, apiKey) {
|
||||
break;
|
||||
}
|
||||
|
||||
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey);
|
||||
const oddsResult = await fetchEventOddsFromApi(sportKey, event.id, apiKey, sport);
|
||||
eventsWithOdds.push(oddsResult.data);
|
||||
lastHeaders = oddsResult.headers;
|
||||
}
|
||||
@@ -230,6 +316,9 @@ module.exports = {
|
||||
getCacheKey,
|
||||
SPORT_KEYS,
|
||||
SOCCER_SPORT_KEYS,
|
||||
// Session 16 — per-sport market scoping.
|
||||
SPORT_MARKETS,
|
||||
getMarketsForSport,
|
||||
getQuotaKey,
|
||||
updateQuota,
|
||||
getQuotaRemaining,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Session 16 — soccer weather wiring. The feature extractor fetches
|
||||
// Open-Meteo for outdoor WC venues. Dome venues skip the fetch
|
||||
// (operators close the roof); unknown venues skip silently.
|
||||
|
||||
const mockCache = new Map();
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
cacheGet: async (k) => (mockCache.has(k) ? mockCache.get(k) : null),
|
||||
cacheSet: async (k, v) => { mockCache.set(k, v); return true; },
|
||||
cacheDel: async (k) => { mockCache.delete(k); return true; },
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const mockWeather = jest.fn();
|
||||
jest.mock('../../src/services/weatherService', () => ({
|
||||
getWeather: (...a) => mockWeather(...a),
|
||||
}));
|
||||
|
||||
const { extractSoccerFeatures } = require('../../src/services/intelligence/soccerFeatureExtractor');
|
||||
const { normalizeName } = require('../../src/utils/normalize');
|
||||
|
||||
beforeEach(() => {
|
||||
mockCache.clear();
|
||||
mockWeather.mockReset();
|
||||
});
|
||||
|
||||
function primePlayerAndMatch(player, team, opts = {}) {
|
||||
mockCache.set(`soccer:player:${normalizeName(player)}`, {
|
||||
team, goals_per_90: 0.5,
|
||||
});
|
||||
mockCache.set(`soccer:nextmatch:${team}`, {
|
||||
opponent: opts.opponent || 'X',
|
||||
venue: opts.venue || 'MetLife Stadium',
|
||||
isHome: opts.isHome ?? true,
|
||||
referee: opts.referee || null,
|
||||
});
|
||||
}
|
||||
|
||||
describe('soccer weather wiring (Session 16)', () => {
|
||||
test('outdoor WC venue → weather features populated', async () => {
|
||||
primePlayerAndMatch('Harry Kane', 'England', { venue: 'MetLife Stadium' });
|
||||
mockWeather.mockResolvedValueOnce({
|
||||
temp_f: 81.2, wind_mph: 9.4, wind_dir: 220, precip_mm: 0,
|
||||
});
|
||||
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
|
||||
expect(mockWeather).toHaveBeenCalledTimes(1);
|
||||
expect(r.features.weather_temp_f).toBeCloseTo(81.2);
|
||||
expect(r.features.weather_wind_mph).toBeCloseTo(9.4);
|
||||
expect(r.features.weather_wind_dir).toBe(220);
|
||||
expect(r.features.weather_precip_mm).toBe(0);
|
||||
});
|
||||
|
||||
test('dome WC venue (BC Place) → weather fetch skipped, fields null', async () => {
|
||||
primePlayerAndMatch('Sub Player', 'Canada', { venue: 'BC Place' });
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Sub Player', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).not.toHaveBeenCalled();
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
expect(r.features.weather_wind_mph).toBeNull();
|
||||
});
|
||||
|
||||
test('Estadio Azteca (open-air, high-altitude) → weather fetched + altitude_impact still high', async () => {
|
||||
primePlayerAndMatch('Forward', 'Mexico', { venue: 'Estadio Azteca' });
|
||||
mockWeather.mockResolvedValueOnce({ temp_f: 68, wind_mph: 5, wind_dir: 90, precip_mm: 0 });
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Forward', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.weather_temp_f).toBe(68);
|
||||
expect(r.features.altitude_impact).toBe('high');
|
||||
});
|
||||
|
||||
test('venue not in the WC index → weather fetch skipped', async () => {
|
||||
primePlayerAndMatch('X', 'Y', { venue: 'Random Stadium' });
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).not.toHaveBeenCalled();
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
});
|
||||
|
||||
test('weather service returns null → feature fields stay null (no throw)', async () => {
|
||||
primePlayerAndMatch('X', 'England', { venue: 'MetLife Stadium' });
|
||||
mockWeather.mockResolvedValueOnce(null);
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).toHaveBeenCalledTimes(1);
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
expect(r.features.weather_wind_mph).toBeNull();
|
||||
});
|
||||
|
||||
test('weather service throws → graceful degrade, grade still produced', async () => {
|
||||
primePlayerAndMatch('X', 'England', { venue: 'MetLife Stadium' });
|
||||
mockWeather.mockRejectedValueOnce(new Error('timeout'));
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'X', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
// Other features (goals_per_90 etc.) still populated.
|
||||
expect(r.features.goals_per_90).toBe(0.5);
|
||||
});
|
||||
|
||||
test('no venue resolved → weather skipped entirely (no fetch attempt)', async () => {
|
||||
mockCache.set(`soccer:player:${normalizeName('Solo')}`, { team: 'England', goals_per_90: 0.5 });
|
||||
// No nextmatch entry → venueName is null.
|
||||
const r = await extractSoccerFeatures({
|
||||
player: 'Solo', stat_type: 'goals', line: 0.5, direction: 'over',
|
||||
});
|
||||
expect(mockWeather).not.toHaveBeenCalled();
|
||||
expect(r.features.weather_temp_f).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
// Session 16 — sport-specific market scoping in oddsService.
|
||||
// Replaces a runtime axios interceptor (NODE_OPTIONS --require) that
|
||||
// had been deployed to filter out cross-sport markets the upstream
|
||||
// odds-api 422s on. These tests pin the contract so the runtime hack
|
||||
// can be retired safely.
|
||||
|
||||
const { SPORT_MARKETS, getMarketsForSport } = require('../../src/services/oddsService');
|
||||
|
||||
describe('SPORT_MARKETS — isolation', () => {
|
||||
test('NBA market list contains no soccer markets', () => {
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_goals\b/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_shots_on_target/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_tackles/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/player_cards/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/team_clean_sheet/);
|
||||
});
|
||||
|
||||
test('NBA market list contains no MLB markets', () => {
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/batter_/);
|
||||
expect(SPORT_MARKETS.nba).not.toMatch(/pitcher_/);
|
||||
});
|
||||
|
||||
test('NBA market list does contain canonical NBA markets', () => {
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_points/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_rebounds/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_assists/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/player_threes/);
|
||||
expect(SPORT_MARKETS.nba).toMatch(/spreads/);
|
||||
});
|
||||
|
||||
test('WNBA market list is NBA-shaped minus PRA combo', () => {
|
||||
expect(SPORT_MARKETS.wnba).toMatch(/player_points/);
|
||||
expect(SPORT_MARKETS.wnba).toMatch(/player_rebounds/);
|
||||
// WNBA odds-api doesn't expose the PRA combo today.
|
||||
expect(SPORT_MARKETS.wnba).not.toMatch(/points_rebounds_assists/);
|
||||
expect(SPORT_MARKETS.wnba).not.toMatch(/batter_/);
|
||||
expect(SPORT_MARKETS.wnba).not.toMatch(/player_goals\b/);
|
||||
});
|
||||
|
||||
test('MLB market list contains batter + pitcher markets, no basketball', () => {
|
||||
expect(SPORT_MARKETS.mlb).toMatch(/batter_home_runs/);
|
||||
expect(SPORT_MARKETS.mlb).toMatch(/batter_hits/);
|
||||
expect(SPORT_MARKETS.mlb).toMatch(/pitcher_strikeouts/);
|
||||
expect(SPORT_MARKETS.mlb).not.toMatch(/player_points/);
|
||||
expect(SPORT_MARKETS.mlb).not.toMatch(/player_goals\b/);
|
||||
});
|
||||
|
||||
test('every soccer league shares the same market list', () => {
|
||||
const soccerKeys = Object.keys(SPORT_MARKETS).filter((k) => k.startsWith('soccer_'));
|
||||
expect(soccerKeys.length).toBeGreaterThanOrEqual(9);
|
||||
const first = SPORT_MARKETS[soccerKeys[0]];
|
||||
for (const k of soccerKeys) {
|
||||
expect(SPORT_MARKETS[k]).toBe(first);
|
||||
}
|
||||
});
|
||||
|
||||
test('soccer market list contains soccer-only markets, no basketball/baseball', () => {
|
||||
const wc = SPORT_MARKETS.soccer_wc;
|
||||
expect(wc).toMatch(/player_goals/);
|
||||
expect(wc).toMatch(/player_shots_on_target/);
|
||||
expect(wc).toMatch(/player_cards/);
|
||||
expect(wc).toMatch(/team_clean_sheet/);
|
||||
expect(wc).not.toMatch(/player_points/);
|
||||
expect(wc).not.toMatch(/batter_/);
|
||||
});
|
||||
|
||||
test('every market list ends with `spreads`', () => {
|
||||
for (const list of Object.values(SPORT_MARKETS)) {
|
||||
// We don't require spreads to be the literal final segment,
|
||||
// only that it's present in the comma-separated list.
|
||||
expect(list.split(',')).toContain('spreads');
|
||||
}
|
||||
});
|
||||
|
||||
test('SPORT_MARKETS is frozen at the top level', () => {
|
||||
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMarketsForSport', () => {
|
||||
test('returns the NBA list for nba', () => {
|
||||
expect(getMarketsForSport('nba')).toBe(SPORT_MARKETS.nba);
|
||||
});
|
||||
test('returns the soccer_wc list for soccer_wc', () => {
|
||||
expect(getMarketsForSport('soccer_wc')).toBe(SPORT_MARKETS.soccer_wc);
|
||||
});
|
||||
test('unknown sport falls back to NBA (safe default)', () => {
|
||||
expect(getMarketsForSport('cricket')).toBe(SPORT_MARKETS.nba);
|
||||
});
|
||||
test('null / undefined / empty fall back to NBA', () => {
|
||||
expect(getMarketsForSport(null)).toBe(SPORT_MARKETS.nba);
|
||||
expect(getMarketsForSport(undefined)).toBe(SPORT_MARKETS.nba);
|
||||
expect(getMarketsForSport('')).toBe(SPORT_MARKETS.nba);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEventOddsFromApi uses sport-scoped markets', () => {
|
||||
// Mock axios so the test doesn't hit the network.
|
||||
jest.resetModules();
|
||||
const mockGet = jest.fn(() => Promise.resolve({ data: {}, headers: {} }));
|
||||
jest.doMock('axios', () => ({ get: mockGet }));
|
||||
const { fetchEventOddsFromApi, SPORT_MARKETS: SM } = require('../../src/services/oddsService');
|
||||
|
||||
beforeEach(() => mockGet.mockClear());
|
||||
|
||||
test('NBA fetch sends NBA markets only', async () => {
|
||||
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key', 'nba');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.nba);
|
||||
});
|
||||
|
||||
test('MLB fetch sends MLB markets only', async () => {
|
||||
await fetchEventOddsFromApi('baseball_mlb', 'evt1', 'key', 'mlb');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.mlb);
|
||||
});
|
||||
|
||||
test('soccer fetch sends soccer markets only', async () => {
|
||||
await fetchEventOddsFromApi('soccer_fifa_world_cup', 'evt1', 'key', 'soccer_wc');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.soccer_wc);
|
||||
});
|
||||
|
||||
test('omitted sport arg falls back to NBA markets (legacy callers)', async () => {
|
||||
await fetchEventOddsFromApi('basketball_nba', 'evt1', 'key');
|
||||
const [, opts] = mockGet.mock.calls[0];
|
||||
expect(opts.params.markets).toBe(SM.nba);
|
||||
});
|
||||
});
|
||||
+6
-2
@@ -15,11 +15,15 @@ import withBundleAnalyzer from '@next/bundle-analyzer';
|
||||
// - Supabase wss: AuthContext realtime + push subscriptions
|
||||
const CSP = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com https://us-assets.i.posthog.com",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline' https://js.stripe.com https://us-assets.i.posthog.com https://browser.sentry-cdn.com",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob: https://*.supabase.co https://cdn.nba.com https://a.espncdn.com",
|
||||
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com https://us.i.posthog.com https://us-assets.i.posthog.com",
|
||||
// Session 16 — Sentry browser client posts events to *.sentry.io
|
||||
// (and *.ingest.sentry.io for the ingestion endpoints). Adding
|
||||
// both forms so the @sentry/nextjs init in SentryInit.tsx can
|
||||
// actually report errors out of the browser bundle.
|
||||
"connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.stripe.com https://us.i.posthog.com https://us-assets.i.posthog.com https://*.sentry.io https://*.ingest.sentry.io",
|
||||
"frame-src https://js.stripe.com https://hooks.stripe.com",
|
||||
"worker-src 'self' blob:",
|
||||
"manifest-src 'self'",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,169 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
// Cache the response for 15 minutes (server-side) so cold visitors
|
||||
// don't trigger a fresh grade on every page load. The cache header
|
||||
// is what most CDNs / Coolify reverse proxies honor; Next.js itself
|
||||
// already opts into dynamic rendering via `dynamic = 'force-dynamic'`.
|
||||
export const revalidate = 900;
|
||||
|
||||
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.
|
||||
*/
|
||||
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' } },
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Falls through to static fallback below.
|
||||
}
|
||||
|
||||
// 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' } },
|
||||
);
|
||||
}
|
||||
+14
-94
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { GradePill } from './GradeCard';
|
||||
// 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.
|
||||
import LiveHeroProp from './LiveHeroProp';
|
||||
|
||||
export default function Hero() {
|
||||
return (
|
||||
@@ -75,7 +79,7 @@ export default function Hero() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FloatingDemoCard />
|
||||
<LiveHeroProp />
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
@@ -173,95 +177,11 @@ function SportBadgeStrip() {
|
||||
);
|
||||
}
|
||||
|
||||
function FloatingDemoCard() {
|
||||
return (
|
||||
<div
|
||||
className="animate-fade-up stagger-3"
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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' }) {
|
||||
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: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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.
|
||||
//
|
||||
// GradePill (re-exported by GradeCard) is still imported at the top
|
||||
// of this file because the section header uses it elsewhere; if a
|
||||
// future cleanup confirms no other usage, that import can drop too.
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { GradePill } from './GradeCard';
|
||||
|
||||
/**
|
||||
* Live hero prop card (Session 16).
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
type HeroPropApi = {
|
||||
isStatic?: 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 }>;
|
||||
};
|
||||
};
|
||||
|
||||
const SPORT_LABEL: Record<string, string> = {
|
||||
nba: 'NBA',
|
||||
wnba: 'WNBA',
|
||||
mlb: 'MLB',
|
||||
soccer_wc: 'World Cup',
|
||||
};
|
||||
|
||||
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' }) {
|
||||
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: tone === 'positive' ? 'var(--grade-a)' : 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LiveHeroProp() {
|
||||
const [data, setData] = useState<HeroPropApi | 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);
|
||||
});
|
||||
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;
|
||||
|
||||
// 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',
|
||||
}}
|
||||
>
|
||||
<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 sportLabel = SPORT_LABEL[sport] || sport.toUpperCase();
|
||||
const sportColor = SPORT_COLOR[sport] || 'var(--grade-a)';
|
||||
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{/* LIVE badge — pulsing dot communicates "this was graded just now". */}
|
||||
<div
|
||||
className="mono"
|
||||
aria-label="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',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: 'var(--grade-a)',
|
||||
boxShadow: '0 0 8px var(--grade-a)',
|
||||
}}
|
||||
/>
|
||||
LIVE
|
||||
</div>
|
||||
|
||||
{/* Header — visible, the hook. */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, marginTop: 8 }}>
|
||||
<div>
|
||||
<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>
|
||||
<p className="mono" style={{ fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||
{lineDisplay}
|
||||
</p>
|
||||
</div>
|
||||
<GradePill grade={gradeText} confidence={confidence} />
|
||||
</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' : 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.'}
|
||||
</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>
|
||||
</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',
|
||||
}}
|
||||
>
|
||||
Sign up to read the full analysis →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user