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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user