-
- Game Lines
+
+ GAME LINES · {lineRows.length} BOOK{lineRows.length === 1 ? '' : 'S'}
+
+
+
BOOK
+
{teamAbbr(awayTeam, sport)} ML
+
{teamAbbr(homeTeam, sport)} ML
+
O/U
+ {lineRows.map((r) => (
+
+ {r.book}
+
+
+
+
+ ))}
- {bookRows.map(([book, line]) => (
-
- {book}
- {line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : '—'}
- {line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : '—'}
- {line.total ? `O/U ${line.total}` : '—'}
-
- ))}
)}
diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js
new file mode 100644
index 0000000..9195753
--- /dev/null
+++ b/web/src/lib/slateAdapter.js
@@ -0,0 +1,126 @@
+/* ============================================================
+ 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,
+ }));
+}
+
+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),
+ 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 || '' }));
+}
+
+module.exports = {
+ parseAmericanOdds,
+ detectBestLines,
+ mapGameLines,
+ mapScheduleToGameCards,
+ formatGameTime,
+};