M1b screen 01: the flat EDGE BOARD — Design's mobile board IA

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) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-16 21:46:47 -04:00
parent f02dc4a6d5
commit 8a24ac9129
5 changed files with 298 additions and 11 deletions
+9
View File
@@ -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
+33 -11
View File
@@ -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 && <MarketBreadth items={breadthItems} />}
<div style={{ display: 'grid', gap: 16 }}>
{orderedGames.map((g, i) => (
<VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex)}
preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')}
/>
))}
</div>
{(() => {
// 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 && (
<div className="edge-board-mobile" style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
<MobileEdgeBoard rows={edgeRows} breadth={breadth} />
</div>
)}
{/* Cards: on mobile they hide ONLY when the flat board is present
(an ungraded slate still shows the game cards on phones). */}
<div className={hasBoard ? 'edge-board-desktop' : ''} style={{ display: 'grid', gap: 16 }}>
{cards.map((card, i) => (
<VyndrGameCard
key={`${card.sport}-${card.away.abbr}-${card.home.abbr}-${i}`}
game={card}
preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')}
/>
))}
</div>
</>
);
})()}
{/* Session 23 — intelligence layer. These coexist WITH the odds
above; they never replace games. Both self-hide when empty, so
@@ -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 <span className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-2)', width: 48, textAlign: 'right' }}></span>;
}
const pos = edge >= 0;
return (
<span className="mono" style={{ fontSize: 12, fontWeight: 700, color: pos ? 'var(--g-a)' : 'var(--miss)', width: 48, textAlign: 'right' }}>
{pos ? '+' : ''}{edge.toFixed(1)}%
</span>
);
}
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 (
<div className="mobile-edge-board">
{/* Breadth strip — EDGES · AVG CLV · GAMES. CLV honest '—' when absent. */}
<div className="mono" style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '10px 16px', borderBottom: '1px solid var(--bg-2)', fontSize: 9.5, letterSpacing: '0.08em', color: 'var(--text-2)' }}>
<span>EDGES <span style={{ color: 'var(--text-0)', fontWeight: 700, fontSize: 11 }}>{breadth.edges}</span></span>
<span>AVG CLV <span style={{ color: clv ? 'var(--g-a)' : 'var(--text-2)', fontWeight: 700, fontSize: 11 }}>{clv || '—'}</span></span>
<span style={{ marginLeft: 'auto' }}>{breadth.games} GAMES</span>
</div>
{/* EDGE BOARD header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px 8px' }}>
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.24em', color: 'var(--text-0)', fontWeight: 700 }}>EDGE BOARD</span>
<span className="mono" style={{ fontSize: 8.5, letterSpacing: '0.14em', color: 'var(--text-2)' }}>EDGE </span>
</div>
{/* 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 (
<a
key={`${r.player}-${r.stat}-${r.line}-${r.rank}`}
href={playerHref(r.player, r.sport)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '11px 16px',
borderTop: '1px solid var(--hairline)', boxShadow: inset,
opacity: rampOpacity(r.rank), color: 'inherit', textDecoration: 'none',
}}
>
<span className="mono" style={{ fontSize: 10, fontWeight: top ? 700 : 600, width: 16, color: top ? 'var(--g-a)' : 'var(--text-2)' }}>
{String(r.rank).padStart(2, '0')}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: r.rank <= 4 ? 700 : 600 }}>
{r.player} <span style={{ color: 'var(--text-1)', fontWeight: 500 }}>{sideCh}{r.line} {r.stat}</span>
</div>
<div className="mono" style={{ fontSize: 8, letterSpacing: '0.1em', color: r.live ? 'var(--g-a)' : 'var(--text-2)', marginTop: 2, display: 'flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap', overflow: 'hidden' }}>
{r.live && <span className="live-dot" style={{ background: 'var(--g-a)', width: 5, height: 5 }} />}
{String(r.sport || '').toUpperCase()} ·
<TeamChip team={r.away} sport={r.sport} size={10} style={{ fontSize: 8 }} /> @
<TeamChip team={r.home} sport={r.sport} size={10} style={{ fontSize: 8 }} />
</div>
</div>
<GradeBadge grade={r.grade} />
<EdgeCell edge={r.edge} />
</a>
);
})}
</div>
);
}
+69
View File
@@ -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.