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:
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
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.) */
|
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.
|
Session 60 (night2/E) — §7 reveal choreography.
|
||||||
Stamp slam + 90ms staggered context panels. Entrance keyframes floor at
|
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 VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
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
|
// 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 —
|
// live games (and it's not a fetch failure) the grid shows REAL data —
|
||||||
// yesterday's proven receipts + tomorrow's date-pinned schedule.
|
// 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. */}
|
graded prop has a real ≥2-book median to compare the model against. */}
|
||||||
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
|
{dateOffset === 0 && <MarketBreadth items={breadthItems} />}
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 16 }}>
|
{(() => {
|
||||||
{orderedGames.map((g, i) => (
|
// Build each game's card once, then present two ways (Rev 3, screen 01):
|
||||||
<VyndrGameCard
|
// MOBILE = Design's FLAT edge-ranked board (all props, one list, sorted
|
||||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
// by edge); DESKTOP = the game-grouped cards. Same data, different IA —
|
||||||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex)}
|
// toggled by width in globals.css (.edge-board-mobile / -desktop).
|
||||||
preferredBooks={preferredBooks}
|
const cards = orderedGames.map((g) => slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex));
|
||||||
onOpen={() => router.push('/scan')}
|
const edgeRows = flattenToEdgeBoard(cards);
|
||||||
/>
|
const breadth = edgeBoardBreadth(edgeRows, cards);
|
||||||
))}
|
const hasBoard = edgeRows.length > 0;
|
||||||
</div>
|
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
|
{/* Session 23 — intelligence layer. These coexist WITH the odds
|
||||||
above; they never replace games. Both self-hide when empty, so
|
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,
|
line: rec.line,
|
||||||
side,
|
side,
|
||||||
grade: rec.grade,
|
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
|
// 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
|
// game's book rows for the graded side (null unless ≥2 books at the
|
||||||
// same current line disagree — see detectBestBook).
|
// same current line disagree — see detectBestBook).
|
||||||
@@ -595,6 +598,69 @@ function pitchersForGameTeams(awayTeam, homeTeam, pitcherMap) {
|
|||||||
return { away: one(a), home: one(h) };
|
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 = {
|
module.exports = {
|
||||||
parseAmericanOdds,
|
parseAmericanOdds,
|
||||||
detectBestLines,
|
detectBestLines,
|
||||||
@@ -611,6 +677,9 @@ module.exports = {
|
|||||||
statShort,
|
statShort,
|
||||||
gradedAgo,
|
gradedAgo,
|
||||||
buildPlayerStripsFromProps,
|
buildPlayerStripsFromProps,
|
||||||
|
flattenToEdgeBoard,
|
||||||
|
edgeBoardBreadth,
|
||||||
|
edgeGradeRank,
|
||||||
buildPitcherMap,
|
buildPitcherMap,
|
||||||
pitchersForGameTeams,
|
pitchersForGameTeams,
|
||||||
// DS2 — dashboard rebuild engine.
|
// DS2 — dashboard rebuild engine.
|
||||||
|
|||||||
Reference in New Issue
Block a user