'use strict'; /** * Content template engine (Session 29). * * Generates STRUCTURED DATA objects (not text, not images) from live VYNDR * data. A separate formatter (contentFormatter) renders text; a future * design layer renders images. * * Templates degrade gracefully by data level: * full → graded props exist → rich content (grade, edge, projection) * lines → Tank01 game lines exist → line-focused content * schedule → only ESPN schedule → matchup/time content * empty → nothing today → minimal, never a crash * * The pure generators take a `data` bundle and never touch the network. * `collectSlateData` is the thin orchestrator that gathers the bundle from * the live services; its collectors are injectable so it stays testable. */ const GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, 'D+': 9, D: 10, 'D-': 11, F: 12 }; const SITE = 'vyndr.app'; // ---- field-alias normalizers (grades come from several shapes) -------- function gradeField(g, ...keys) { for (const k of keys) if (g && g[k] != null && g[k] !== '') return g[k]; return null; } function normalizeGrade(g) { return { player: gradeField(g, 'player_name', 'player'), stat: gradeField(g, 'stat_type', 'stat'), line: gradeField(g, 'line'), side: gradeField(g, 'side', 'direction'), grade: gradeField(g, 'grade') || 'C', confidence: Number(gradeField(g, 'confidence')) || 0, edge: Number(gradeField(g, 'edge', 'edge_pct')) || 0, projection: gradeField(g, 'projection'), matchup: gradeField(g, 'matchup'), killConditions: g?.killConditions || g?.kill_conditions_triggered || [], analysis: gradeField(g, 'analysis', 'one_line_reason') || (g?.reasoning && g.reasoning.summary) || null, _raw: g, }; } // ---- data level ------------------------------------------------------- function determineDataLevel(data) { const grades = data?.grades || []; const gameLines = data?.gameLines || {}; const schedule = data?.schedule || []; if (grades.length > 0) return 'full'; if (gameLines && Object.keys(gameLines).length > 0) return 'lines'; if (schedule.length > 0) return 'schedule'; return 'empty'; } function prettyDate(now = Date.now()) { return new Date(now).toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }); } function sportLabel(sport) { return String(sport || '').toUpperCase(); } // ---- game-line highlight extraction ----------------------------------- function numOdds(v) { const n = Number(v); return Number.isFinite(n) ? n : null; } function americanToDecimal(odds) { const n = Number(odds); if (!Number.isFinite(n) || n === 0) return null; return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n); } // books: array of [bookName, { homeML, awayML, total, homeSpread, ... }] function bestOdds(books, field) { let best = null; for (const [book, line] of books) { const dec = americanToDecimal(line?.[field]); if (dec == null) continue; if (!best || dec > best.decimal) best = { book, odds: line[field], decimal: dec }; } return best; } function consensus(books, field) { const vals = books.map(([, l]) => Number(l?.[field])).filter((n) => Number.isFinite(n)); if (vals.length === 0) return null; return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 10) / 10; } // Disagreement = spread between best & worst home-ML decimal payout. function bookDisagreement(books) { const decs = books.map(([, l]) => americanToDecimal(l?.homeML)).filter((n) => n != null); if (decs.length < 2) return 0; return Math.round((Math.max(...decs) - Math.min(...decs)) * 100) / 100; } function findGameLinesFor(game, gameLines) { if (!gameLines) return null; const h = (game.homeTeam?.abbreviation || '').toUpperCase(); const a = (game.awayTeam?.abbreviation || '').toUpperCase(); for (const entry of Object.values(gameLines)) { const eh = String(entry.homeTeam || '').toUpperCase(); const ea = String(entry.awayTeam || '').toUpperCase(); if ((eh === h && ea === a) || (eh === a && ea === h)) return entry; } return null; } function extractGameLineHighlights(schedule, gameLines) { const highlights = []; for (const game of schedule || []) { const lines = findGameLinesFor(game, gameLines); const books = lines && lines.books ? Object.entries(lines.books) : []; if (books.length === 0) continue; highlights.push({ type: 'game_line_highlight', game: `${game.awayTeam?.abbreviation || '?'} @ ${game.homeTeam?.abbreviation || '?'}`, time: game.gameTime || null, venue: game.venue || null, bookCount: books.length, bestHomeML: bestOdds(books, 'homeML'), bestAwayML: bestOdds(books, 'awayML'), total: consensus(books, 'total'), spread: consensus(books, 'homeSpread'), disagreement: bookDisagreement(books), }); } return highlights.sort((a, b) => (b.disagreement || 0) - (a.disagreement || 0)); } // ---- hook + CTA ------------------------------------------------------- function generateHookText(sport, data) { const label = sportLabel(sport); const date = prettyDate(); const games = (data.schedule || []).length; const bookCount = maxBookCount(data.gameLines); if (data.dataLevel === 'full') { return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games graded${bookCount ? ` · lines from ${bookCount} books` : ''}. Top reads below. 🧵`; } if (data.dataLevel === 'lines') { return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games · lines from ${bookCount} sportsbooks. Here's what stands out. 🧵`; } if (data.dataLevel === 'schedule') { return `🔥 VYNDR SLATE — ${date}. ${games} ${label} games on the board today. 🧵`; } return `VYNDR — ${date}. No ${label} games today. Back tomorrow.`; } function maxBookCount(gameLines) { if (!gameLines) return 0; let max = 0; for (const entry of Object.values(gameLines)) { const n = entry && entry.books ? Object.keys(entry.books).length : 0; if (n > max) max = n; } return max; } function generateCTAText(dataLevel) { if (dataLevel === 'empty') return `Full slate + intelligence at ${SITE}.`; return `Full slate live at ${SITE}. Free tier: reads on us. Founder pricing from $14.99/mo.`; } function generateAnalysisLine(grade) { if (grade.analysis) return grade.analysis; const edge = grade.edge ? `${grade.edge > 0 ? '+' : ''}${grade.edge}% edge` : null; const proj = grade.projection != null ? `projection ${grade.projection}` : null; return [proj, edge].filter(Boolean).join(' · ') || 'Model edge on this line.'; } // ---- slate thread ----------------------------------------------------- function generateSlateThread(sport, data) { const dataLevel = data.dataLevel || determineDataLevel(data); const enriched = { ...data, dataLevel }; const thread = { type: 'slate_thread', sport, date: prettyDate(), dataLevel, posts: [] }; thread.posts.push({ role: 'hook', text: generateHookText(sport, enriched) }); if (dataLevel === 'full') { const top = (data.grades || []).map(normalizeGrade) .sort((a, b) => b.confidence - a.confidence) .slice(0, 5); for (const g of top) { thread.posts.push({ role: 'pick', player: g.player, stat: g.stat, line: g.line, side: g.side, grade: g.grade, confidence: g.confidence, edge: g.edge, projection: g.projection, analysis: generateAnalysisLine(g), cardData: g._raw, }); } } else if (dataLevel === 'lines') { const highlights = extractGameLineHighlights(data.schedule, data.gameLines); for (const h of highlights.slice(0, 4)) thread.posts.push({ role: 'game_highlight', ...h }); if ((data.movers || []).length > 0) { thread.posts.push({ role: 'movers', movers: data.movers.slice(0, 3) }); } } else if (dataLevel === 'schedule') { const games = (data.schedule || []).slice(0, 8).map((g) => ({ away: g.awayTeam?.name || g.awayTeam?.abbreviation || null, home: g.homeTeam?.name || g.homeTeam?.abbreviation || null, time: g.gameTime || null, venue: g.venue || null, })); thread.posts.push({ role: 'schedule', games }); } thread.posts.push({ role: 'cta', text: generateCTAText(dataLevel) }); return thread; } // ---- prop / game of the day ------------------------------------------- function generatePOTD(sport, data) { const dataLevel = data.dataLevel || determineDataLevel(data); if (dataLevel === 'full' && (data.grades || []).length > 0) { const best = (data.grades || []).map(normalizeGrade).reduce((a, b) => (b.confidence > a.confidence ? b : a)); return { type: 'potd', dataLevel: 'full', player: best.player, stat: best.stat, line: best.line, side: best.side, grade: best.grade, confidence: best.confidence, edge: best.edge, projection: best.projection, matchup: best.matchup, killConditions: best.killConditions, analysis: generateAnalysisLine(best), cardData: best._raw, }; } if (dataLevel === 'lines') { const top = extractGameLineHighlights(data.schedule, data.gameLines)[0]; if (top) return { type: 'potd', dataLevel: 'lines', subtype: 'game_of_the_day', ...top }; } return { type: 'potd', dataLevel: 'empty', available: false }; } // ---- results recap (pure, takes resolved grades) ---------------------- function gradeTier(grade) { return String(grade || '').charAt(0).toUpperCase(); } function tierRecord(grades, tier) { const inTier = grades.filter((g) => gradeTier(gradeField(g, 'grade')) === tier); const wins = inTier.filter((g) => g.result === 'win').length; const losses = inTier.filter((g) => g.result === 'loss').length; return { wins, losses, total: inTier.length }; } function brierScore(grades) { // Brier = mean( (p - outcome)^2 ); p from confidence%, outcome 1 win / 0 loss. const scored = grades.filter((g) => g.result === 'win' || g.result === 'loss'); if (scored.length === 0) return null; const sum = scored.reduce((acc, g) => { const p = (Number(gradeField(g, 'confidence')) || 50) / 100; const outcome = g.result === 'win' ? 1 : 0; return acc + (p - outcome) ** 2; }, 0); return Math.round((sum / scored.length) * 1000) / 1000; } function avgCLV(grades) { const vals = grades.map((g) => Number(gradeField(g, 'clv'))).filter((n) => Number.isFinite(n)); if (vals.length === 0) return null; return Math.round((vals.reduce((a, b) => a + b, 0) / vals.length) * 100) / 100; } function generateResultsRecap(sport, resolvedGrades) { if (!Array.isArray(resolvedGrades) || resolvedGrades.length === 0) { return { type: 'recap', available: false }; } const wins = resolvedGrades.filter((g) => g.result === 'win'); const losses = resolvedGrades.filter((g) => g.result === 'loss'); const pushes = resolvedGrades.filter((g) => g.result === 'push'); const decided = wins.length + losses.length; const byConfidence = (arr) => [...arr].sort((a, b) => (Number(gradeField(b, 'confidence')) || 0) - (Number(gradeField(a, 'confidence')) || 0)); return { type: 'recap', available: true, sport, date: prettyDate(), record: { wins: wins.length, losses: losses.length, pushes: pushes.length }, winRate: decided > 0 ? Math.round((wins.length / decided) * 1000) / 1000 : null, topHits: byConfidence(wins).slice(0, 3).map((g) => ({ player: gradeField(g, 'player_name', 'player'), stat: gradeField(g, 'stat_type', 'stat'), line: gradeField(g, 'line'), side: gradeField(g, 'side', 'direction'), grade: gradeField(g, 'grade'), actual: gradeField(g, 'actual'), })), biggestMiss: losses.length > 0 ? (() => { const m = byConfidence(losses)[0]; return { player: gradeField(m, 'player_name', 'player'), stat: gradeField(m, 'stat_type', 'stat'), line: gradeField(m, 'line'), side: gradeField(m, 'side', 'direction'), grade: gradeField(m, 'grade'), actual: gradeField(m, 'actual'), }; })() : null, byTier: { A: tierRecord(resolvedGrades, 'A'), B: tierRecord(resolvedGrades, 'B'), C: tierRecord(resolvedGrades, 'C') }, metrics: { brierScore: brierScore(resolvedGrades), clv: avgCLV(resolvedGrades) }, }; } // ---- matchup preview -------------------------------------------------- function generateMatchupNarrative(game, lines, streaks) { const away = game.awayTeam?.name || game.awayTeam?.abbreviation || 'Away'; const home = game.homeTeam?.name || game.homeTeam?.abbreviation || 'Home'; const matched = (streaks || []).length; if (lines && lines.total != null) { return `${away} at ${home} — consensus total ${lines.total}${matched ? `, ${matched} active player streak${matched === 1 ? '' : 's'} in play` : ''}.`; } return `${away} at ${home}${matched ? ` — ${matched} player${matched === 1 ? '' : 's'} riding a streak` : ''}.`; } function generateMatchupPreview(game, gameLines, streaks) { const books = gameLines && gameLines.books ? Object.entries(gameLines.books) : []; const lines = books.length > 0 ? { spread: consensus(books, 'homeSpread'), total: consensus(books, 'total'), homeFavorite: (() => { const hs = consensus(books, 'homeSpread'); return hs != null ? hs < 0 : null; })(), bookCount: books.length, } : null; const h = (game.homeTeam?.abbreviation || '').toUpperCase(); const a = (game.awayTeam?.abbreviation || '').toUpperCase(); const playerStreaks = (streaks || []).filter((s) => { const t = (s.team || '').toUpperCase(); return t && (t === h || t === a); }); return { type: 'matchup_preview', away: game.awayTeam || null, home: game.homeTeam || null, time: game.gameTime || null, venue: game.venue || null, lines, playerStreaks, narrative: generateMatchupNarrative(game, lines, playerStreaks), }; } // ---- orchestrator (injectable collectors for testability) ------------- function defaultCollectors() { const scheduleService = require('./scheduleService'); const lineSnapshotService = require('./lineSnapshotService'); const bookComparisonService = require('./bookComparisonService'); const streaksService = require('./streaksService'); const { loadRosterLogs } = require('./rosterLogs'); const { cacheGet } = require('../utils/redis'); const nbaAdapter = require('./adapters/tank01NbaAdapter'); const mlbAdapter = require('./adapters/tank01MlbAdapter'); const { __internals: glInternals } = require('../routes/gameLines'); return { getSchedule: (sport, date) => scheduleService.getSchedule(sport, date), getGameLines: async (sport, date) => { const fetcher = sport === 'nba' ? nbaAdapter.getNBABettingOdds : sport === 'mlb' ? mlbAdapter.getMLBBettingOdds : null; if (!fetcher) return {}; const body = await fetcher(date).catch(() => null); return body ? glInternals.normalizeGameLines(body) : {}; }, getGrades: async (sport) => { // odds-api grades cache (when present); degrade to [] otherwise. const utc = new Date().toISOString().split('T')[0]; const cache = (await cacheGet(`grades:${sport}:${utc}`)) ?? (await cacheGet(`grades:${sport}`)); return Array.isArray(cache) ? cache : Array.isArray(cache?.grades) ? cache.grades : []; }, getStreaks: async (sport) => { const roster = await loadRosterLogs(sport); return streaksService.computeStreaks(roster, sport, {}); }, getMovers: (sport) => lineSnapshotService.getBiggestMovers(sport, { limit: 10 }), getBestLines: async (sport) => { const utc = new Date().toISOString().split('T')[0]; const cache = (await cacheGet(`odds:${sport}:${utc}`)) ?? (await cacheGet(`odds:${sport}`)); const props = Array.isArray(cache?.props) ? cache.props : []; return bookComparisonService.bestLines(props, { limit: 10 }); }, }; } async function collectSlateData(sport, deps) { const c = deps || defaultCollectors(); const date = (() => { try { return require('./scheduleService').todayET(); } catch { return undefined; } })(); const [schedule, gameLines, grades, streaks, movers, bestLines] = await Promise.allSettled([ c.getSchedule(sport, date), c.getGameLines(sport, date), c.getGrades(sport), c.getStreaks(sport), c.getMovers(sport), c.getBestLines(sport), ]); const val = (r, fallback) => (r.status === 'fulfilled' && r.value != null ? r.value : fallback); const data = { sport, schedule: val(schedule, []), gameLines: val(gameLines, {}), grades: val(grades, []), streaks: val(streaks, []), movers: val(movers, []), bestLines: val(bestLines, []), }; data.dataLevel = determineDataLevel(data); return data; } module.exports = { collectSlateData, determineDataLevel, generateSlateThread, generatePOTD, generateResultsRecap, generateMatchupPreview, __internals: { normalizeGrade, extractGameLineHighlights, generateHookText, generateCTAText, brierScore, avgCLV, tierRecord, bestOdds, consensus, bookDisagreement, findGameLinesFor, }, };