From fe294a5de398a14dda683a15baec3b51af0649c4 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 00:07:06 -0400 Subject: [PATCH] =?UTF-8?q?DS2:=20Dashboard=20Slate=20Rebuild=20=E2=80=94?= =?UTF-8?q?=20one=20hero,=20pending=20collapse,=20never-empty=20hero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN-SPEC Parts 3 + 6 (audit #1, #13, #14). The founder's named #1 rebuild. slateAdapter.js — the testable engine: - selectTopGrades: rank tonight's grades by tier → confidence → edge so the row varies on a real signal, not identical-weight noise (#13). - buildHeroReceipts: yesterday's PROVEN A-tier settled HITS (misses excluded), carrying the real result — the never-empty proof source (#1, Part 6). - heroFallbackState: tonight wins, else receipts, else empty. - pendingSummary: collapse an all-awaiting card's six "Grades post …" rows to ONE line count (#14). - topReadForCard: the single best live graded read to promote (#2). GameCard.tsx — ONE bold hero per card (large mono/tabular grade + player, rest demoted); all-awaiting cards render one "N props pending · grade ~X ET" line via nextRunLabelET instead of repeated filler. Real team logos + team-colored accent already lead the card (DS0) — preserved. dashboard/page.tsx — Top grades tonight ranked via selectTopGrades (+ % CONF the varying signal); when tonight is empty, fetch /api/ledger/model and fall back to yesterday's PROVEN A-tier receipts (✓ HIT + actual + CLV) so first paint always proves the model. Honest nextRunLabelET copy kept for the truly-empty case (QA.22). Tests: tests/unit/ds2Dashboard.test.js (21) — pure-fn + source assertions, fail-before / pass-after. Full suite 237 suites / 2863 tests green (+21). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/ds2Dashboard.test.js | 194 ++++++++++++++++++++++++ web/src/app/dashboard/page.tsx | 206 ++++++++++++++++++++------ web/src/components/vyndr/GameCard.tsx | 39 ++++- web/src/lib/slateAdapter.js | 140 +++++++++++++++++ 4 files changed, 531 insertions(+), 48 deletions(-) create mode 100644 tests/unit/ds2Dashboard.test.js diff --git a/tests/unit/ds2Dashboard.test.js b/tests/unit/ds2Dashboard.test.js new file mode 100644 index 0000000..2b43e94 --- /dev/null +++ b/tests/unit/ds2Dashboard.test.js @@ -0,0 +1,194 @@ +// DS2 (Design v2) — Dashboard Slate Rebuild. The founder's named #1 rebuild: +// real entities per card, ONE bold hero, collapse pending-filler (#14), and the +// NEVER-EMPTY hero (#1, Part 6) — first paint ALWAYS proves the model. The pure +// engine lives in web/src/lib/slateAdapter.js (unit-tested directly); the .tsx +// surfaces are asserted against source (plain-JS Jest) — same pattern as +// ds4Billboards / vyndrParityQA. + +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const WEB = path.join(ROOT, 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const adapter = require('../../web/src/lib/slateAdapter'); + +// ── 1. selectTopGrades — ranking VARIES on a real signal (#13) ───────────── +describe('selectTopGrades — tier first, then the varying signal (confidence/edge)', () => { + test('orders by grade tier, then confidence desc, then edge desc', () => { + const grades = [ + { player: 'C-low', grade: 'B', confidence: 40, edge: 1 }, + { player: 'A-hiconf', grade: 'A', confidence: 90, edge: 2 }, + { player: 'A-loconf', grade: 'A', confidence: 55, edge: 9 }, + { player: 'Aplus', grade: 'A+', confidence: 10, edge: 0 }, + ]; + const out = adapter.selectTopGrades(grades, 10).map((g) => g.player); + expect(out).toEqual(['Aplus', 'A-hiconf', 'A-loconf', 'C-low']); + }); + + test('ties on grade break on confidence — rows are NOT identical-order noise', () => { + const grades = [ + { player: 'X', grade: 'B', confidence: 30 }, + { player: 'Y', grade: 'B', confidence: 80 }, + { player: 'Z', grade: 'B', confidence: 55 }, + ]; + expect(adapter.selectTopGrades(grades, 10).map((g) => g.player)).toEqual(['Y', 'Z', 'X']); + }); + + test('honors the limit and drops gradeless rows; empty in → empty out', () => { + const grades = [{ grade: 'A', confidence: 1 }, { grade: 'A', confidence: 2 }, { confidence: 9 }]; + expect(adapter.selectTopGrades(grades, 1)).toHaveLength(1); + expect(adapter.selectTopGrades([], 5)).toEqual([]); + expect(adapter.selectTopGrades(null, 5)).toEqual([]); + }); +}); + +// ── 2. buildHeroReceipts — yesterday's A-tier settled HITS (#1) ──────────── +describe('buildHeroReceipts — proven A-tier receipts, misses excluded', () => { + const rows = [ + { player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A+', outcome: 'hit', actual_value: 3, clv_result: 'beat', sport: 'mlb' }, + { player_name: 'Missed Guy', stat: 'hits', line: 1.5, side: 'over', grade: 'A', outcome: 'miss', actual_value: 0 }, + { player_name: 'B Guy', stat: 'runs', line: 0.5, side: 'over', grade: 'B', outcome: 'hit', actual_value: 1 }, + { player_name: 'Aminus Hit', stat: 'rbi', line: 0.5, side: 'over', grade: 'A-', outcome: 'hit', actual_value: 2 }, + ]; + + test('keeps only A-family grades that HIT (a miss never becomes proof)', () => { + const out = adapter.buildHeroReceipts(rows, 8); + const names = out.map((r) => r.player); + expect(names).toContain('Aaron Judge'); + expect(names).toContain('Aminus Hit'); + expect(names).not.toContain('Missed Guy'); // A-tier but a MISS + expect(names).not.toContain('B Guy'); // a hit but not A-tier + }); + + test('carries the real result (actual + grade) so the receipt is verifiable', () => { + const judge = adapter.buildHeroReceipts(rows, 8).find((r) => r.player === 'Aaron Judge'); + expect(judge).toMatchObject({ grade: 'A+', outcome: 'hit', actual: 3 }); + }); + + test('best-grade first, honors the limit, empty/absent → []', () => { + expect(adapter.buildHeroReceipts(rows, 8)[0].grade).toBe('A+'); + expect(adapter.buildHeroReceipts(rows, 1)).toHaveLength(1); + expect(adapter.buildHeroReceipts([], 5)).toEqual([]); + expect(adapter.buildHeroReceipts(null, 5)).toEqual([]); + }); +}); + +// ── 3. heroFallbackState — the NEVER-EMPTY hero engine (#1, Part 6) ──────── +describe('heroFallbackState — tonight wins, else receipts, never empty when proof exists', () => { + const settled = [{ player_name: 'Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A', outcome: 'hit', actual_value: 3 }]; + + test('tonight has graded props → mode tonight', () => { + const s = adapter.heroFallbackState([{ player: 'P', grade: 'A', confidence: 70 }], settled, 10); + expect(s.mode).toBe('tonight'); + expect(s.items).toHaveLength(1); + }); + + test('tonight EMPTY but yesterday has A-tier hits → mode receipts (proof, not empty)', () => { + const s = adapter.heroFallbackState([], settled, 10); + expect(s.mode).toBe('receipts'); + expect(s.items[0].player).toBe('Judge'); + }); + + test('only truly empty when BOTH are absent', () => { + expect(adapter.heroFallbackState([], [], 10).mode).toBe('empty'); + expect(adapter.heroFallbackState(null, null, 10).mode).toBe('empty'); + }); +}); + +// ── 4. pendingSummary — collapse the dead "Grades post" repetition (#14) ── +describe('pendingSummary — six pending rows collapse to ONE line', () => { + test('all-awaiting card → one summary counting props + players', () => { + const strips = [ + { player: 'One', props: [{ stat: 'TB', line: 1.5, awaiting: true }, { stat: 'HR', line: 0.5, awaiting: true }] }, + { player: 'Two', props: [{ stat: 'Hits', line: 0.5, awaiting: true }] }, + ]; + const s = adapter.pendingSummary(strips); + expect(s).not.toBeNull(); + expect(s.count).toBe(3); + expect(s.players).toBe(2); + }); + + test('any graded prop present → no collapse (the card has real reads to show)', () => { + const strips = [ + { player: 'One', props: [{ stat: 'TB', line: 1.5, grade: 'A' }] }, + { player: 'Two', props: [{ stat: 'Hits', line: 0.5, awaiting: true }] }, + ]; + expect(adapter.pendingSummary(strips)).toBeNull(); + }); + + test('no awaiting props at all → null', () => { + expect(adapter.pendingSummary([{ player: 'One', props: [] }])).toBeNull(); + expect(adapter.pendingSummary([])).toBeNull(); + expect(adapter.pendingSummary(null)).toBeNull(); + }); +}); + +// ── 5. topReadForCard — the ONE bold hero per card (#2) ──────────────────── +describe('topReadForCard — the single best graded prop leads the card', () => { + const strips = [ + { player: 'Mid', team: 'NYY', props: [{ stat: 'Hits', line: 0.5, side: 'O', grade: 'B' }] }, + { player: 'Best', team: 'BOS', archetype: { primary: 'BOMBER' }, props: [{ stat: 'TB', line: 1.5, side: 'O', grade: 'A+' }] }, + { player: 'Dead', team: 'LAD', props: [{ stat: 'HR', line: 0.5, side: 'O', grade: 'A+', dead: true }] }, + ]; + + test('picks the highest-tier live grade (dead reads never lead)', () => { + const top = adapter.topReadForCard(strips); + expect(top).not.toBeNull(); + expect(top.player).toBe('Best'); + expect(top.grade).toBe('A+'); + expect(top.stat).toBe('TB'); + }); + + test('no graded props → null (nothing to promote)', () => { + expect(adapter.topReadForCard([{ player: 'X', props: [{ stat: 'TB', line: 1, awaiting: true }] }])).toBeNull(); + expect(adapter.topReadForCard([])).toBeNull(); + }); +}); + +// ── 6. GameCard.tsx — one hero + pending collapse + real entities ───────── +describe('GameCard.tsx wires the DS2 rebuild', () => { + const src = read('components/vyndr/GameCard.tsx'); + + test('leads with the real team entity (logo) + a team-colored accent edge', () => { + expect(src).toContain(' { + expect(src).toContain('topReadForCard'); + expect(src).toMatch(/fontVariantNumeric:\s*'tabular-nums'/); + // the hero grade badge is the largest on the card (strips use size 'sm'). + expect(src).toMatch(/size=\{48\}|size="lg"/); + }); + + test('collapses all-awaiting cards to ONE pending line (kills the #14 repetition)', () => { + expect(src).toContain('pendingSummary'); + expect(src).toContain('nextRunLabelET'); + expect(src).toContain('props pending'); + }); +}); + +// ── 7. dashboard/page.tsx — the never-empty hero + varying rankings ──────── +describe('dashboard Top grades tonight — proof-first, ranked, honest', () => { + const src = read('app/dashboard/page.tsx'); + + test('uses the never-empty engine (heroFallbackState / buildHeroReceipts)', () => { + expect(src).toMatch(/heroFallbackState|buildHeroReceipts/); + }); + + test('ranks tonight grades by the varying signal (selectTopGrades) and shows confidence', () => { + expect(src).toContain('selectTopGrades'); + expect(src).toContain('confidence'); + }); + + test('falls back to yesterday PROVEN A-tier receipts (with the settled result)', () => { + expect(src).toContain('/api/ledger/model'); + expect(src).toMatch(/HIT|PROVEN|YESTERDAY/); + }); + + test('still keeps the honest schedule-derived waiting copy for the truly-empty case (QA.22)', () => { + expect(src).toContain('nextRunLabelET'); + }); +}); diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 76c4460..9e2f603 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -15,6 +15,11 @@ import { AccuracyBadge, Skeleton, SkeletonList } from '@/components/vyndr'; import { emptyStateCopy } from '@/lib/emptyState'; // Session 59 (work-order 2.3) — the real pipeline schedule for waiting states. import { nextRunLabelET } from '@/lib/pipelineSchedule'; +// DS2 (Design v2) — the never-empty hero engine: rank tonight's grades on a +// varying signal, and fall back to yesterday's PROVEN A-tier receipts so first +// paint ALWAYS proves the model (#1, #13, Part 6). +import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter'; +import GradeBadge from '@/components/vyndr/GradeBadge'; import { currentAccessToken } from '@/lib/authToken'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -61,6 +66,32 @@ interface ParlayLegStat { parlay_count: number; } +// DS2 — a PROVEN receipt (yesterday's settled A-tier hit) for the never-empty hero. +interface HeroReceipt { + player: string; + stat: string; + line: number; + side: string; + grade: string; + sport: string; + outcome: string; + actual: number | null; + clvResult: string | null; +} + +// The /api/ledger/model settled-row shape (subset we read for receipts). +interface ModelEntry { + player_name?: string; + sport?: string; + stat?: string; + line?: number; + side?: string; + grade?: string; + outcome?: string | null; + actual_value?: number | null; + clv_result?: string | null; +} + interface RecentScan { id: string; player_name: string; @@ -86,6 +117,9 @@ export default function DashboardPage() { const [sport, setSport] = useState('NBA'); const [games, setGames] = useState(null); const [topGrades, setTopGrades] = useState(null); + // DS2 — yesterday's PROVEN receipts, fetched only when tonight has no grades + // yet (the never-empty hero fallback). null = not yet loaded. + const [heroReceipts, setHeroReceipts] = useState(null); const [mostParlayed, setMostParlayed] = useState(null); const [recentScans, setRecentScans] = useState(null); // Session 49 — the user's primary sport tab + preferred books from prefs. @@ -127,6 +161,7 @@ export default function DashboardPage() { let cancelled = false; setGames(null); setTopGrades(null); + setHeroReceipts(null); Promise.all([ fetch(`/api/games/tonight?sport=${sport}`).then((r) => r.json()).catch(() => ({ games: [] })), @@ -163,6 +198,24 @@ export default function DashboardPage() { }; }, [sport]); + // DS2 (#1, Part 6) — the NEVER-EMPTY hero fallback. When tonight's slate has + // no graded props yet, pull the model's most-recent settled reads and keep + // only the PROVEN A-tier hits (buildHeroReceipts). First paint always proves + // the model works (the real 55-19 / 74% record). Best-effort, self-hiding. + useEffect(() => { + if (topGrades === null || topGrades.length > 0) return; // tonight has grades → no fallback needed + let cancelled = false; + fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled) return; + const entries: ModelEntry[] = Array.isArray(data?.entries) ? data.entries : []; + setHeroReceipts(buildHeroReceipts(entries, 8) as HeroReceipt[]); + }) + .catch(() => { if (!cancelled) setHeroReceipts([]); }); + return () => { cancelled = true; }; + }, [topGrades, sport]); + // Most parlayed + recent scans don't depend on sport useEffect(() => { fetch('/api/props/most-parlayed') @@ -253,57 +306,116 @@ export default function DashboardPage() { }} /> - {/* Top grades horizontal scroll */} -
- {topGrades === null ? ( - - ) : topGrades.length === 0 ? ( - // Session 59 (work-order 2.3) — the real schedule, not passive waiting. -

No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.

- ) : ( -
{ + const ranked = topGrades ? selectTopGrades(topGrades, 10) as TopGrade[] : []; + const proofMode = topGrades !== null && ranked.length === 0 && (heroReceipts?.length ?? 0) > 0; + return ( +
- {topGrades.map((g, i) => ( - - ))} -
- )} -
+ {ranked.map((g, i) => ( + + ))} + + ) : proofMode ? ( + // The never-empty proof: yesterday's PROVEN A-tier receipts. +
+ {(heroReceipts || []).map((r, i) => ( +
+
+ YESTERDAY · PROVEN + +
+

{r.player}

+

+ {String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')} +

+

+ ✓ HIT{r.actual != null ? ` (${r.actual})` : ''} + {r.clvResult === 'beat' ? · CLV BEAT : null} +

+
+ ))} +
+ ) : heroReceipts === null ? ( + // tonight empty, fallback still loading → skeleton, never a text wall. + + ) : ( + // Truly nothing yet — the honest schedule-derived waiting copy (QA.22). +

No grades yet. Grades post {nextRunLabelET() || 'on the next pipeline run'}.

+ )} + + ); + })()} {/* Tonight's games */}
diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index c80c053..ef68c2a 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -10,6 +10,8 @@ import TeamLogo from '@/components/vyndr/TeamLogo'; import { accentColor } from '@/lib/teamMeta'; import { playerHref } from '@/lib/playerHref'; import { isPreferredBook } from '@/lib/books'; +import { pendingSummary, topReadForCard } from '@/lib/slateAdapter'; +import { nextRunLabelET } from '@/lib/pipelineSchedule'; import { useParlay, legKey } from '@/contexts/ParlayContext'; export interface GameLine { @@ -182,6 +184,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks const [linesExpanded, setLinesExpanded] = useState(false); const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]); const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible; + // DS2 (#2) — the ONE bold hero per card: the single best live graded read, + // promoted large + mono + tabular. Everything else in the strips below stays + // demoted context. (#14) — when the card has NO graded reads yet, the awaiting + // props collapse to ONE pending line instead of six identical rows. + const topRead = useMemo(() => topReadForCard(g.playerStrips || []), [g.playerStrips]); + const pending = useMemo(() => pendingSummary(g.playerStrips || []), [g.playerStrips]); // A1 S11 — LIVE SLATE MODE: any strip prop carrying live tracking shows the // once-per-card label. Grades locked pre-game NEVER change in-game. const isTracking = useMemo( @@ -321,7 +329,36 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks )} - {g.playerStrips && g.playerStrips.length > 0 ? ( + {/* DS2 (#2) — ONE bold hero: the card's best read, large + mono + + tabular. The failure mode was every value at identical weight; this + promotes the single number that matters and demotes the rest. */} + {topRead && !pending && ( +
+ +
+
{topRead.player}
+
+ {topRead.stat} {String(topRead.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{topRead.line} + {topRead.team ? · {topRead.team} : null} +
+
+ TOP READ +
+ )} + {/* DS2 (#14) — collapse the dead "Grades post …" repetition. An + all-awaiting card renders ONE compact line, not six identical rows. */} + {pending ? ( +
+ {pending.count} + props pending + · + {pending.players} player{pending.players === 1 ? '' : 's'} + · + {nextRunLabelET() ? `grade ${nextRunLabelET()}` : 'grade on the next pipeline run'} +
+ ) : g.playerStrips && g.playerStrips.length > 0 ? (
{stripsToRender.map((ps, i) => ( { + const n = typeof v === 'number' ? v : parseFloat(v); + return Number.isFinite(n) ? n : fallback; +}; + +/** + * #13 — rank tonight's grades by TIER, then the VARYING signal so a leaderboard + * of near-identical rows stops being noise: confidence desc, then |edge| desc, + * stable by input order. Drops gradeless rows. Returns at most `limit`. + */ +function selectTopGrades(grades, limit = 10) { + const arr = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade); + const scored = arr.map((g, idx) => ({ + g, + idx, + rank: gradeRankOf(g.grade), + conf: numOr(g.confidence, -1), + edge: Math.abs(numOr(g.edge, -Infinity)), + })); + scored.sort((a, b) => a.rank - b.rank || b.conf - a.conf || b.edge - a.edge || a.idx - b.idx); + return scored.slice(0, Math.max(0, limit)).map((s) => s.g); +} + +/** + * #1 / Part 6 — build PROVEN receipts from settled ledger rows: only A-tier + * grades that HIT (a miss never becomes proof). Carries the real result so the + * receipt is verifiable. Best-grade first. `settledRows` = /api/ledger/model + * entries (player_name, stat, line, side, grade, outcome, actual_value, …). + */ +function buildHeroReceipts(settledRows, limit = 8) { + const rows = (Array.isArray(settledRows) ? settledRows : []).filter( + (r) => r && String(r.outcome || '').toLowerCase() === 'hit' && gradeRankOf(r.grade) <= 2, + ); + const mapped = rows.map((r) => ({ + player: displayName(r.player_name || r.player || ''), + stat: r.stat, + line: r.line, + side: String(r.side || 'over'), + grade: r.grade, + sport: String(r.sport || '').toUpperCase(), + outcome: 'hit', + actual: r.actual_value != null ? r.actual_value : null, + clvResult: r.clv_result || null, + })); + mapped.sort((a, b) => gradeRankOf(a.grade) - gradeRankOf(b.grade)); + return mapped.slice(0, Math.max(0, limit)); +} + +/** + * #1 / Part 6 — the never-empty hero engine. Tonight's ranked top grades win; + * when tonight is empty, fall back to yesterday's PROVEN A-tier receipts; only + * `empty` when both are absent. First paint ALWAYS proves the model when any + * settled proof exists. + */ +function heroFallbackState(tonightGrades, settledRows, limit = 10) { + const tonight = selectTopGrades(tonightGrades, limit); + if (tonight.length > 0) return { mode: 'tonight', items: tonight }; + const receipts = buildHeroReceipts(settledRows, Math.min(limit, 8)); + if (receipts.length > 0) return { mode: 'receipts', items: receipts }; + return { mode: 'empty', items: [] }; +} + +/** + * #14 — collapse the dead per-prop "Grades post …" repetition. When a card has + * NO graded reads yet (every prop awaiting), returns a single summary + * ({ count, players, statLabels }) so the UI renders ONE compact line instead + * of six identical rows. Any graded prop present → null (there are real reads + * to show). No awaiting props → null. + */ +function pendingSummary(strips) { + const list = Array.isArray(strips) ? strips : []; + let awaiting = 0; + let graded = 0; + const players = new Set(); + const statLabels = []; + for (const s of list) { + for (const p of s.props || []) { + if (p.grade) graded += 1; + else if (p.awaiting) { + awaiting += 1; + players.add(s.player); + if (statLabels.length < 6) statLabels.push(`${p.stat} ${p.line}`); + } + } + } + if (graded > 0 || awaiting === 0) return null; + return { count: awaiting, players: players.size, statLabels }; +} + +/** + * #2 — the ONE bold hero per card: the single highest-tier LIVE graded prop + * (dead/not-in reads never lead). Returns { player, team, archetype, stat, + * line, side, grade } or null when the card has no graded reads. + */ +function topReadForCard(strips) { + let best = null; + for (const s of Array.isArray(strips) ? strips : []) { + for (const p of s.props || []) { + if (!p.grade || p.dead) continue; + const rank = gradeRankOf(p.grade); + if (!best || rank < best.rank) { + best = { + rank, + player: s.player, + team: s.team || '', + archetype: s.archetype || null, + stat: p.stat, + line: p.line, + side: p.side || 'O', + grade: p.grade, + }; + } + } + } + if (!best) return null; + const { rank, ...rest } = best; // eslint-disable-line no-unused-vars + return rest; +} + // ── 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] : ''; }; @@ -468,4 +601,11 @@ module.exports = { buildPlayerStripsFromProps, buildPitcherMap, pitchersForGameTeams, + // DS2 — dashboard rebuild engine. + gradeRankOf, + selectTopGrades, + buildHeroReceipts, + heroFallbackState, + pendingSummary, + topReadForCard, };