From 8a24ac91291b80a6d8fd9ffdbdd90e9006352fa0 Mon Sep 17 00:00:00 2001 From: Kev Date: Thu, 16 Jul 2026 21:46:47 -0400 Subject: [PATCH] =?UTF-8?q?M1b=20screen=2001:=20the=20flat=20EDGE=20BOARD?= =?UTF-8?q?=20=E2=80=94=20Design's=20mobile=20board=20IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one genuinely-new mobile screen. Design's mobile BOARD is a FLAT edge-ranked list (all graded props across every game on one list, sorted by edge) — not the desktop's game-grouped cards. Implemented to the drawing with REAL snapshot data: - slateAdapter.flattenToEdgeBoard(cards) — pure transform of the assembled GameCardData[] (grade→game join already done) into ranked rows, edge desc. STRICT null edge sorts LAST (never 0-coerced to the top — Data Semantics Rule). Threaded edge_pct through buildPlayerStripsFromProps (was dropped). 6 unit tests. - MobileEdgeBoard component — Design's exact screen-01 rows: rank (green #1), player + prop, matchup sub-line with TeamChips + live-dot, tier grade chip, and the edge% as the one bold mono hero (green +, red −). Ranked opacity ramp (1 → .55) + green inset border on the top reads. Breadth strip EDGES/AVG CLV/ GAMES — CLV honest '—' (per-slate CLV isn't computed; never fabricated). - Slate: <768px renders the flat board, ≥768px keeps game cards (same data, toggled by width). Ungraded slate still shows game cards on phones (no blank). Built to Design's screen-01 drawing, VISUALLY UNVERIFIED at 390px — the core mobile screen, top of the master-audit list. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/edgeBoard.test.js | 73 ++++++++++++ web/src/app/globals.css | 9 ++ web/src/components/Slate.tsx | 44 +++++-- web/src/components/vyndr/MobileEdgeBoard.tsx | 114 +++++++++++++++++++ web/src/lib/slateAdapter.js | 69 +++++++++++ 5 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 tests/unit/edgeBoard.test.js create mode 100644 web/src/components/vyndr/MobileEdgeBoard.tsx diff --git a/tests/unit/edgeBoard.test.js b/tests/unit/edgeBoard.test.js new file mode 100644 index 0000000..d80c449 --- /dev/null +++ b/tests/unit/edgeBoard.test.js @@ -0,0 +1,73 @@ +'use strict'; + +// Rev 3 — flat EDGE BOARD (mobile screen 01). Design's mobile board is a flat, +// edge-ranked list across every game, not game-grouped cards. The flatten is a +// pure transform of the assembled GameCardData[]; lock its ranking + honesty. + +const { flattenToEdgeBoard, edgeBoardBreadth, edgeGradeRank } = require('../../web/src/lib/slateAdapter'); + +const card = (over) => ({ + sport: 'nba', live: false, + away: { abbr: 'DEN', name: 'Denver' }, home: { abbr: 'MIN', name: 'Minnesota' }, + playerStrips: [], ...over, +}); +const strip = (player, props, extra = {}) => ({ player, team: extra.team || '', props }); + +describe('flattenToEdgeBoard', () => { + const cards = [ + card({ playerStrips: [ + strip('Jokic', [{ stat: 'PTS', line: 27.5, side: 'O', grade: 'A+', edge: 8.4 }]), + strip('Gordon', [{ stat: 'REB', line: 7.5, side: 'O', grade: 'B', edge: 3.4 }]), + ] }), + card({ live: true, away: { abbr: 'CHI', name: 'Chi' }, home: { abbr: 'MIL', name: 'Mil' }, playerStrips: [ + strip('Edwards', [{ stat: 'AST', line: 5.5, side: 'O', grade: 'A', edge: 5.2 }]), + strip('LaVine', [{ stat: 'PTS', line: 22.5, side: 'O', grade: 'D', edge: -1.2 }]), + ] }), + ]; + + test('flattens every graded prop across games into ranked rows, edge desc', () => { + const rows = flattenToEdgeBoard(cards); + expect(rows.map((r) => r.player)).toEqual(['Jokic', 'Edwards', 'Gordon', 'LaVine']); // 8.4, 5.2, 3.4, -1.2 + expect(rows[0].rank).toBe(1); + expect(rows[3].rank).toBe(4); + expect(rows[1].live).toBe(true); // Edwards' game is live → row carries it + expect(rows[0].away).toBe('DEN'); expect(rows[0].home).toBe('MIN'); + }); + + test('a null edge sorts LAST, never 0-coerced to the top', () => { + const rows = flattenToEdgeBoard([card({ playerStrips: [ + strip('NoEdge', [{ stat: 'PTS', line: 20, side: 'O', grade: 'A', edge: null }]), + strip('HasEdge', [{ stat: 'PTS', line: 20, side: 'O', grade: 'C', edge: 2.0 }]), + ] })]); + expect(rows[0].player).toBe('HasEdge'); // +2.0 beats a null (not 0) + expect(rows[1].player).toBe('NoEdge'); + }); + + test('awaiting / dead / ungraded rows are excluded from the board', () => { + const rows = flattenToEdgeBoard([card({ playerStrips: [ + strip('Graded', [{ stat: 'PTS', line: 20, side: 'O', grade: 'A', edge: 5 }]), + strip('Awaiting', [{ stat: 'PTS', line: 20, side: 'O', grade: null, awaiting: true }]), + strip('Dead', [{ stat: 'PTS', line: 20, side: 'O', grade: 'B', edge: 4, dead: true }]), + ] })]); + expect(rows.map((r) => r.player)).toEqual(['Graded']); + }); + + test('breadth counts A/B edges + games, never fabricates CLV', () => { + const rows = flattenToEdgeBoard(cards); + const b = edgeBoardBreadth(rows, cards); + expect(b.edges).toBe(3); // A+, B, A (not the D) + expect(b.games).toBe(2); + expect(b).not.toHaveProperty('clv'); // CLV comes from /api/accuracy, never invented here + }); + + test('edgeGradeRank orders tiers for the tiebreak', () => { + expect(edgeGradeRank('A+')).toBeLessThan(edgeGradeRank('A')); + expect(edgeGradeRank('A')).toBeLessThan(edgeGradeRank('B')); + expect(edgeGradeRank('F')).toBeLessThan(edgeGradeRank('ZZZ')); // unknown → last + }); + + test('empty / malformed input → empty board, no throw', () => { + expect(flattenToEdgeBoard(null)).toEqual([]); + expect(flattenToEdgeBoard([{}])).toEqual([]); + }); +}); diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 75cd4b5..624431d 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1414,6 +1414,15 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; } SPECIFIC hero sizes, not one generic clamp: 74px grade tier, 40px live tier, 24px /u stats. Those land per-surface in M1b, not as a global class.) */ + +/* Rev 3 — flat EDGE BOARD is the mobile board (Design screen 01); desktop keeps + the game-grouped cards. */ +.edge-board-mobile { display: none; } +@media (max-width: 767px) { + .edge-board-mobile { display: block; } + .edge-board-desktop { display: none !important; } +} + /* ============================================================ Session 60 (night2/E) — §7 reveal choreography. Stamp slam + 90ms staggered context panels. Entrance keyframes floor at diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index b3192f4..94c5a83 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -7,7 +7,8 @@ import { useRouter } from 'next/navigation'; import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard'; import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard'; import { PropRowProp, Tier } from '@/components/PropRow'; -import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter'; +import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey, flattenToEdgeBoard, edgeBoardBreadth } from '@/lib/slateAdapter'; +import MobileEdgeBoard from '@/components/vyndr/MobileEdgeBoard'; // Wave 4A (Step 3) — OUTLOOK MODE: the never-empty grid. When there are no // live games (and it's not a fetch failure) the grid shows REAL data — // yesterday's proven receipts + tomorrow's date-pinned schedule. @@ -1181,16 +1182,37 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook graded prop has a real ≥2-book median to compare the model against. */} {dateOffset === 0 && } -
- {orderedGames.map((g, i) => ( - router.push('/scan')} - /> - ))} -
+ {(() => { + // Build each game's card once, then present two ways (Rev 3, screen 01): + // MOBILE = Design's FLAT edge-ranked board (all props, one list, sorted + // by edge); DESKTOP = the game-grouped cards. Same data, different IA — + // toggled by width in globals.css (.edge-board-mobile / -desktop). + const cards = orderedGames.map((g) => slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex)); + const edgeRows = flattenToEdgeBoard(cards); + const breadth = edgeBoardBreadth(edgeRows, cards); + const hasBoard = edgeRows.length > 0; + return ( + <> + {hasBoard && ( +
+ +
+ )} + {/* Cards: on mobile they hide ONLY when the flat board is present + (an ungraded slate still shows the game cards on phones). */} +
+ {cards.map((card, i) => ( + router.push('/scan')} + /> + ))} +
+ + ); + })()} {/* Session 23 — intelligence layer. These coexist WITH the odds above; they never replace games. Both self-hide when empty, so diff --git a/web/src/components/vyndr/MobileEdgeBoard.tsx b/web/src/components/vyndr/MobileEdgeBoard.tsx new file mode 100644 index 0000000..6c829a9 --- /dev/null +++ b/web/src/components/vyndr/MobileEdgeBoard.tsx @@ -0,0 +1,114 @@ +'use client'; + +import GradeBadge from '@/components/vyndr/GradeBadge'; +import TeamChip from '@/components/vyndr/TeamChip'; +import { playerHref } from '@/lib/playerHref'; + +/** + * MobileEdgeBoard (Rev 3, mobile screen 01) — Design's FLAT edge-ranked board. + * All graded props across every game on one ranked list, sorted by edge, with + * the ranked opacity ramp (1 → .55 down the board), a green inset border on the + * top reads, matchup chips, and the edge% as the one bold mono hero per row + * (green +, red −). Fed by slateAdapter.flattenToEdgeBoard — this is pure + * presentation. Renders on phones (<768px); desktop keeps the game-grouped cards. + */ + +export interface EdgeBoardRow { + rank: number; + player: string; + team?: string; + stat: string; + line: number | string; + side: string; // 'O' | 'U' + grade: string; + edge: number | null; + sport: string; + away: string; + home: string; + live?: boolean; + outcome?: { result?: string } | null; +} + +// Design's ranked opacity ramp: top two full, then progressively dim. +function rampOpacity(rank: number): number { + if (rank <= 2) return 1; + if (rank === 3) return 1; // live row reads full in Design + if (rank === 4) return 0.85; + if (rank === 5) return 0.7; + return Math.max(0.5, 0.7 - (rank - 5) * 0.06); +} + +function EdgeCell({ edge }: { edge: number | null }) { + if (edge == null) { + return ; + } + const pos = edge >= 0; + return ( + + {pos ? '+' : ''}{edge.toFixed(1)}% + + ); +} + +export default function MobileEdgeBoard({ + rows, + breadth, + clv = null, +}: { + rows: EdgeBoardRow[]; + breadth: { edges: number; games: number }; + clv?: string | null; +}) { + if (!Array.isArray(rows) || rows.length === 0) return null; + return ( +
+ {/* Breadth strip — EDGES · AVG CLV · GAMES. CLV honest '—' when absent. */} +
+ EDGES {breadth.edges} + AVG CLV {clv || '—'} + {breadth.games} GAMES +
+ + {/* EDGE BOARD header */} +
+ EDGE BOARD + EDGE ▼ +
+ + {/* Ranked rows */} + {rows.map((r) => { + const top = r.rank === 1; + const inset = r.rank === 1 ? 'inset 2px 0 0 rgba(0,212,160,.55)' : r.rank <= 3 ? 'inset 2px 0 0 rgba(0,212,160,.4)' : 'none'; + const sideCh = String(r.side || 'O').toUpperCase().startsWith('U') ? 'u' : 'o'; + return ( + + + {String(r.rank).padStart(2, '0')} + +
+
+ {r.player} {sideCh}{r.line} {r.stat} +
+
+ {r.live && } + {String(r.sport || '').toUpperCase()} · + @ + +
+
+ + +
+ ); + })} +
+ ); +} diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index effa9f1..bd71305 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -382,6 +382,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat line: rec.line, side, grade: rec.grade, + // Rev 3 flat edge-board (mobile screen 01) — the ranked hero figure. + // Strict null (never 0-coerced) so an absent edge sorts last, not top. + edge: (rec.edge_pct != null && Number.isFinite(Number(rec.edge_pct))) ? Number(rec.edge_pct) : null, // 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). @@ -595,6 +598,69 @@ function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) { return { away: one(a), home: one(h) }; } +// ── Rev 3 — flat EDGE BOARD (mobile screen 01) ────────────────────────── +// Design's mobile board is a FLAT, edge-ranked list (all graded props across +// every game, sorted by edge), not game-grouped cards. This flattens the +// GameCardData[] the Slate already assembled (grade→game join already done) +// into ranked rows. Pure + testable. Sort: edge desc (STRICT null sorts LAST — +// never 0-coerced to the top), grade rank as tiebreak. +const EDGE_GRADE_RANK = { 'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, C: 6, D: 7, F: 8 }; +function edgeGradeRank(g) { + return EDGE_GRADE_RANK[g] != null ? EDGE_GRADE_RANK[g] : 9; +} +function flattenToEdgeBoard(cards) { + const rows = []; + for (const c of Array.isArray(cards) ? cards : []) { + const away = (c.away && c.away.abbr) || ''; + const home = (c.home && c.home.abbr) || ''; + for (const strip of (c.playerStrips || [])) { + for (const p of (strip.props || [])) { + if (!p || !p.grade || p.awaiting || p.dead) continue; // only live graded rows + rows.push({ + player: strip.player, + team: strip.team || '', + stat: p.stat, + statType: p.statType, + line: p.line, + side: p.side || 'O', + grade: p.grade, + edge: (p.edge != null && Number.isFinite(p.edge)) ? p.edge : null, + sport: c.sport || '', + away, + home, + live: !!c.live, + outcome: p.outcome || null, + movement: p.movement || null, + playerId: strip.playerId, + espnId: strip.espnId, + headshotUrl: strip.headshotUrl, + }); + } + } + } + rows.sort((a, b) => { + const ea = a.edge == null ? -Infinity : a.edge; + const eb = b.edge == null ? -Infinity : b.edge; + if (eb !== ea) return eb - ea; + return edgeGradeRank(a.grade) - edgeGradeRank(b.grade); + }); + return rows.map((r, i) => ({ ...r, rank: i + 1 })); +} + +/** Breadth-strip numbers for the mobile board header (Design screen 01): + * EDGES = count of A/B-tier graded rows · GAMES = games with ≥1 graded row. + * AVG CLV is NOT computed here (comes from /api/accuracy; honest '—' when + * absent — Design draws it but the row-level model doesn't produce per-slate + * CLV). Never fabricates. */ +function edgeBoardBreadth(rows, games) { + const list = Array.isArray(rows) ? rows : []; + const edges = list.filter((r) => { + const t = String(r.grade || '')[0]; + return t === 'A' || t === 'B'; + }).length; + return { edges, games: Array.isArray(games) ? games.length : 0 }; +} + module.exports = { parseAmericanOdds, detectBestLines, @@ -611,6 +677,9 @@ module.exports = { statShort, gradedAgo, buildPlayerStripsFromProps, + flattenToEdgeBoard, + edgeBoardBreadth, + edgeGradeRank, buildPitcherMap, pitchersForGameTeams, // DS2 — dashboard rebuild engine.