/* ============================================================ VYNDR 2.0 — slate adapter (§7, §E.1). Merges schedule + gamelines + streaks + grades into the GameCard contract, and detects the best/worst book line per game (the Bloomberg pattern — the #1 visual upgrade). Plain CommonJS so the .tsx cards import it (allowJs) AND Jest exercises the logic directly. ============================================================ */ /** American odds → decimal payout multiplier (higher = better for the bettor). * "+150" → 2.5, "-110" → ~1.909. Returns null when unparseable. */ function parseAmericanOdds(odds) { if (odds == null) return null; const n = typeof odds === 'number' ? odds : parseInt(String(odds).replace(/[^\d+-]/g, ''), 10); if (!Number.isFinite(n) || n === 0) return null; return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n); } /** Mark the most/least favorable moneyline per side across a game's books. * Input: { book1: { awayML, homeML, total }, ... } → rows with best/worst flags. * best/worst only set when ≥2 books disagree (a lone price isn't "best"). */ function detectBestLines(books) { const entries = Object.entries(books || {}); const rows = entries.map(([book, ln]) => ({ book, awayML: ln.awayML || '—', homeML: ln.homeML || '—', ou: ln.total != null ? `O/U ${ln.total}` : '—', _away: parseAmericanOdds(ln.awayML), _home: parseAmericanOdds(ln.homeML), })); const mark = (side) => { const vals = rows.map((r) => r[side]).filter((v) => v != null); if (vals.length < 2) return [null, null]; const max = Math.max(...vals); const min = Math.min(...vals); return max === min ? [null, null] : [max, min]; }; const [bestAway, worstAway] = mark('_away'); const [bestHome, worstHome] = mark('_home'); return rows.map((r) => ({ book: r.book, awayML: r.awayML, homeML: r.homeML, ou: r.ou, bestAway: r._away != null && r._away === bestAway, worstAway: r._away != null && r._away === worstAway, bestHome: r._home != null && r._home === bestHome, worstHome: r._home != null && r._home === worstHome, })); } /** * Best available price across a prop's book rows (A1 Session 3). * * `rows` is the grouped odds shape the Express /api/odds proxy already * ships the browser: [{ book, line, over_odds, under_odds }] — one row * per book for the same player+stat. * * Data-semantics rule: a "best price" claim is only honest when ≥2 books * post the SAME line for the side and their prices differ — comparing * odds across different lines is meaningless, and a lone price isn't * "best". Anything else → null. Absent beats wrong. * * @param {Array<{book?:string,line?:number,over_odds?:number|null,under_odds?:number|null}>} rows * @param {string} [side] — 'over' | 'under' (default over) * @param {number|null} [refLine] — the line the card displays; when given, * only books at that exact line compete. * @returns {{ book: string, odds: number } | null} */ function detectBestBook(rows, side = 'over', refLine = null) { const key = String(side || 'over').toLowerCase().startsWith('u') ? 'under_odds' : 'over_odds'; const valid = (Array.isArray(rows) ? rows : []).filter( (r) => r && r.book && Number.isFinite(r.line) && r[key] != null && parseAmericanOdds(r[key]) != null, ); if (valid.length < 2) return null; // Compare at ONE line: the displayed line when given, else the modal line. let line = Number.isFinite(refLine) ? refLine : null; if (line == null) { const counts = new Map(); for (const r of valid) counts.set(r.line, (counts.get(r.line) || 0) + 1); let bestCount = 0; for (const [ln, c] of counts) if (c > bestCount) { bestCount = c; line = ln; } } const atLine = valid.filter((r) => r.line === line); if (atLine.length < 2) return null; const decimals = atLine.map((r) => parseAmericanOdds(r[key])); const max = Math.max(...decimals); if (max === Math.min(...decimals)) return null; // identical prices → no "best" const winner = atLine[decimals.indexOf(max)]; return { book: winner.book, odds: winner[key] }; } function formatGameTime(iso) { if (!iso) return ''; try { return new Date(iso).toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' }); } catch { return String(iso); } } /** Map one schedule game's lines entry → the GameCard `lines[]` contract. */ function mapGameLines(linesEntry) { if (!linesEntry || !linesEntry.books) return []; return detectBestLines(linesEntry.books); } /** * Map schedule + gamelines + streaks (+ optional grades) → GameCardData[] * (§7). Pure — no API calls, no side effects. */ function mapScheduleToGameCards(schedule, gamelines, streaks, grades) { const sched = Array.isArray(schedule) ? schedule : []; return sched.map((g) => { const id = g.id || `${g.awayTeam?.abbreviation || '?'}-${g.homeTeam?.abbreviation || '?'}`; const live = g.live === true || g.status === 'in'; const linesEntry = gamelines && gamelines[id]; return { id, sport: (g.sport || 'nba').toLowerCase(), live, score: g.score ? { away: g.score.away, home: g.score.home } : undefined, clock: g.clock || undefined, away: { abbr: g.awayTeam?.abbreviation || '', name: g.awayTeam?.name || '' }, home: { abbr: g.homeTeam?.abbreviation || '', name: g.homeTeam?.name || '' }, time: formatGameTime(g.gameTime), venue: g.venue || undefined, lines: mapGameLines(linesEntry), props: mapGradedProps(grades, g), // Session 43 — design enhanced-card fields (consumed by vyndr/GameCard; // legacy GameCard ignores the extras). playerStrips = props grouped so the // name appears once; pitchers = MLB probables when published. playerStrips: groupPropsByPlayer(mapGradedProps(grades, g)), pitchers: mapPitchers({ ...g, sport: g.sport }), streaks: mapStreaks(streaks, g), }; }); } function mapGradedProps(grades, game) { if (!Array.isArray(grades)) return []; const h = (game.homeTeam?.abbreviation || '').toUpperCase(); const a = (game.awayTeam?.abbreviation || '').toUpperCase(); return grades .filter((p) => { const t = (p.team || '').toUpperCase(); return !t || t === h || t === a; }) .map((p) => ({ player: p.player, stat: p.stat, line: p.line, grade: p.grade, side: p.side || 'Over', delta: p.delta })); } function mapStreaks(streaks, game) { if (!Array.isArray(streaks)) return []; const h = (game.homeTeam?.abbreviation || '').toUpperCase(); const a = (game.awayTeam?.abbreviation || '').toUpperCase(); return streaks .filter((s) => { const t = (s.team || '').toUpperCase(); return t && (t === h || t === a); }) .map((s) => ({ player: s.player, text: s.text || s.description || '' })); } /** * Group graded props by player → the design's enhanced-card `playerStrips` * (Session 43): player name once, archetype + stats placeholder, all props on * one line. `archetypeLookup(player)` is optional (sync) — when absent the * strip renders without an archetype badge (still valid). */ function groupPropsByPlayer(props, archetypeLookup) { if (!Array.isArray(props)) return []; const byPlayer = {}; const order = []; for (const p of props) { if (!p || !p.player) continue; if (!byPlayer[p.player]) { const archetype = typeof archetypeLookup === 'function' ? archetypeLookup(p.player) : undefined; byPlayer[p.player] = { player: p.player, team: p.team || '', archetype: archetype || undefined, stats: [], // season stats wired when the stats cache lands (Session 44) props: [], }; order.push(p.player); } byPlayer[p.player].props.push({ stat: p.stat, line: p.line, side: (p.side || 'Over').toString().charAt(0).toUpperCase(), grade: p.grade, }); } return order.map((name) => byPlayer[name]); } /** * Map an MLB schedule game's probable pitchers → the GameCard `pitchers` shape. * Returns undefined for non-MLB or when no probables are published. */ function mapPitchers(game) { if (!game || String(game.sport || '').toLowerCase() !== 'mlb') return undefined; const a = game.away?.probablePitcher || game.awayPitcher; const h = game.home?.probablePitcher || game.homePitcher; if (!a && !h) return undefined; const one = (p, era, arch) => ({ name: (p && (p.name || p.fullName)) || (typeof p === 'string' ? p : '') || 'TBD', era: era != null ? String(era) : (p && p.era != null ? String(p.era) : '—'), archetype: arch || (p && p.archetype) || undefined, }); return { away: one(a, game.awayPitcherERA, game.awayPitcherArchetype), home: one(h, game.homePitcherERA, game.homePitcherArchetype), }; } /** * Should this game still show on the slate (Session 44)? Upcoming + live games * always show; a COMPLETED game is dropped once it's more than 24h old, so a * 5-day-old FINAL never lingers on the dashboard. Unknown/missing date → keep * (degrade open). `now` is injectable for tests. */ function isRelevantGame(game, now = Date.now()) { if (!game) return false; const state = String(game.state || game.status || '').toLowerCase(); const isFinal = state === 'final' || state === 'post' || state === 'closed' || state === 'complete'; if (!isFinal) return true; const raw = game.date || game.gameTime || game.commence_time || game.startTime; const t = raw ? new Date(raw).getTime() : NaN; if (Number.isNaN(t)) return true; // no parseable date → don't hide return (now - t) / 3_600_000 < 24; } // ── Pre-graded snapshot overlay (Session 45) ──────────────────────── // Session 46/47 — key by the normalized name (nicknames/accents/periods/parens) // so variants merge; DISPLAY the de-dotted, paren-stripped form. const { nameKey, normalizeName } = require('./playerName'); const displayName = (raw) => normalizeName(raw).display || String(raw || ''); const gradeKey = (player, stat) => `${nameKey(player)}|${String(stat || '').toLowerCase()}`; const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'U' : 'O'); /** Index snapshot grades by player|stat → the locked grade record. */ function indexGrades(grades) { const map = {}; for (const g of grades || []) { map[gradeKey(g.player || g.player_name, g.stat_type || g.stat)] = g; } return map; } /** Index line deltas by player|stat|side → delta record. */ function indexDeltas(deltas) { const map = {}; for (const d of deltas || []) { map[`${gradeKey(d.player, d.stat)}|${d.side}`] = d; } return map; } const STAT_SHORT = { total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs', strikeouts: 'Ks', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP', stolen_bases: 'SB', points: 'Pts', rebounds: 'Reb', assists: 'Ast', threes: '3PT', steals: 'Stl', blocks: 'Blk', pra: 'PRA', turnovers: 'TO', }; function statShort(stat) { if (!stat) return ''; return STAT_SHORT[stat] || String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } /** Relative "Graded Xh ago" from an ISO timestamp. */ function gradedAgo(iso, now = Date.now()) { const t = iso ? new Date(iso).getTime() : NaN; if (Number.isNaN(t)) return ''; const mins = Math.max(0, Math.round((now - t) / 60000)); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.round(mins / 60); if (hrs < 24) return `${hrs}h ago`; return `${Math.round(hrs / 24)}d ago`; } /** Team-identity match by nickname token (last word) — "New York Yankees" * ↔ "Yankees"; exact string match also accepted. */ function slateTeamsMatch(a, b) { if (!a || !b) return false; const sa = String(a).toLowerCase(), sb = String(b).toLowerCase(); if (sa === sb) return true; return teamMascot(a) !== '' && teamMascot(a) === teamMascot(b); } /** * Build pre-graded `playerStrips` for one game by OVERLAYING the snapshot's * locked grades onto the game's odds-derived props (which already carry the * correct game grouping). Each prop is either graded (grade + gradedAt + delta) * or `awaiting:true` (no snapshot match yet → "Awaiting next scan", no Read * button). Archetype comes from the snapshot's per-player classification. * * Session 59 (work-order 1.6) — THE JOIN INVARIANT: when the snapshot knows * the player's REAL team (grades carry `team` from the stats resolve) and * the caller passes the game's participants (`gameTeams`), a prop whose * player does NOT belong to either team is DROPPED from the card entirely — * a bad feed row must not render a TB player under MIL@PIT. Props without * team info are kept (can't verify ≠ wrong). */ /** * @param {Array} gameProps * @param {Record} gradeIndex * @param {Record} deltaIndex * @param {number} [now] * @param {{home?: string, away?: string} | null} [gameTeams] */ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now(), gameTeams = null) { const byPlayer = {}; const order = []; for (const p of gameProps || []) { if (!p || !p.player) continue; // Session 46 — group by the normalized key so name variants ("A.J. Ewing" // / "AJ Ewing") merge into ONE strip; display the longest seen variant. const pk = nameKey(p.player); const rec = gradeIndex[gradeKey(p.player, p.stat_type || p.stat)]; // Join guard: known player team that isn't in this game → bad row, drop. const knownTeam = (rec && rec.team) || p.team || ''; if (knownTeam && gameTeams && (gameTeams.home || gameTeams.away)) { const inGame = slateTeamsMatch(knownTeam, gameTeams.home) || slateTeamsMatch(knownTeam, gameTeams.away); if (!inGame) continue; } if (!byPlayer[pk]) { byPlayer[pk] = { player: displayName(p.player), team: knownTeam, archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined, stats: [], props: [], }; order.push(pk); } else { // Display the longest (most complete) de-dotted variant seen. const cand = displayName(p.player); if (cand.length > String(byPlayer[pk].player).length) byPlayer[pk].player = cand; if (!byPlayer[pk].archetype && rec && rec.archetype) byPlayer[pk].archetype = { primary: rec.archetype }; } if (rec) { const side = sideCh(rec.direction); const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`]; byPlayer[pk].props.push({ stat: statShort(rec.stat_type || rec.stat), line: rec.line, side, grade: rec.grade, // A1 S3 — the prop's own book + the best available price across the // game's book rows for the graded side (null unless ≥2 books at the // same current line disagree — see detectBestBook). book: p.book || null, bestBook: detectBestBook(p.books, side === 'U' ? 'under' : 'over', p.line), gradedAt: rec.gradedAt ? { ...rec.gradedAt, ago: gradedAgo(rec.gradedAt.timestamp, now) } : null, delta: delta ? { delta: delta.delta, direction: delta.direction, currentLine: delta.currentLine } : null, // Session 55 — settled outcome from the self-learning loop (hit/miss/push // + actual stat) once the game completes. null until settled. outcome: rec.outcome ? { result: rec.outcome.result, actual: rec.outcome.actual } : null, // Session 60 (Phase 2.5) — intraday movement (STEAM/VALUE/revised) // + the ORIGINAL grade when a public revision happened. movement: rec.movement || null, revisedFrom: rec.revised_from_grade || null, }); } else { byPlayer[pk].props.push({ stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true, book: p.book || null, bestBook: detectBestBook(p.books, p.direction || 'over', p.line), }); } } // Session 48 — one prop row per stat (variant dupes like "Ks 5.5" + "Ks 3.5" // from "Matt"/"Matthew" collapse). Prefer the graded prop over an awaiting one. return order.map((key) => { const e = byPlayer[key]; const byStat = new Map(); for (const pr of e.props) { const sk = String(pr.stat).toLowerCase(); const ex = byStat.get(sk); if (!ex || (pr.grade && !ex.grade)) byStat.set(sk, pr); } return { ...e, props: [...byStat.values()] }; }); } // ── MLB probable pitchers (Session 46) ────────────────────────────── const teamToken = (name) => String(name == null ? '' : name).toLowerCase().replace(/[^a-z0-9 ]/g, '').trim(); const teamMascot = (name) => { const t = teamToken(name).split(' ').filter(Boolean); return t.length ? t[t.length - 1] : ''; }; /** Index probable-pitcher games by team (full + mascot) → { pitcher, era }. */ function buildPitcherMap(pitcherGames) { const map = {}; for (const g of pitcherGames || []) { for (const side of [g.home, g.away]) { if (!side || !side.pitcher || !side.team) continue; const entry = { name: side.pitcher, era: side.era != null ? String(side.era) : null }; map[teamToken(side.team)] = entry; const m = teamMascot(side.team); if (m && map[m] == null) map[m] = entry; } } return map; } /** Resolve { away, home } pitchers for a game's team names → GameCard shape. */ function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) { if (!pitcherMap) return undefined; const look = (name) => pitcherMap[teamToken(name)] || pitcherMap[teamMascot(name)] || null; const a = look(awayTeam); const h = look(homeTeam); if (!a && !h) return undefined; const one = (p) => ({ name: (p && p.name) || 'TBD', era: (p && p.era) || '—' }); return { away: one(a), home: one(h) }; } module.exports = { parseAmericanOdds, detectBestLines, detectBestBook, mapGameLines, mapScheduleToGameCards, formatGameTime, groupPropsByPlayer, mapPitchers, isRelevantGame, indexGrades, indexDeltas, statShort, gradedAgo, buildPlayerStripsFromProps, buildPitcherMap, pitchersForGameTeams, };