Session 35: Design system Phase D — core screens: Grade Result, Slate card, Scan, Terminal, Landing (1839 tests)
VYNDR 2.0 conversion, Phase D (the screens users touch). Frontend-only; zero backend changes. - GradeResultCard + ProcessingGrade (the core product moment): intel-surface grade hero, signal breakdown, kill conditions, best-book strip, alt ladder; sections self-hide when empty. - lib/gradeAdapter.js maps engine output -> §7 contract and tier-gates content (free teaser / analyst kill-conditions / desk alt ladder) so the new card doesn't give paid content away. - Scan result wired to ProcessingGrade->GradeResultCard, preserving scan limits, parlay add, reads tracking, and noopener sportsbook deep-links. - GameCard (Bloomberg best/worst line cells) built + tested. - Terminal page replaces its stub with a real league-intelligence screen. - Landing gets the founder-seat ClaimMeter. Honest scope: live dashboard/Slate swap onto GameCard, scan input -> TerminalInput, full landing rebuild, and the blurred-paywall polish (Phase G) are deferred to keep working flows stable. 22 new tests. Backend 1818 -> 1839, 143 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/* ============================================================
|
||||
VYNDR 2.0 — grade adapter (§7).
|
||||
Maps our grading-engine output (the /api/scan ScanResponse shape +
|
||||
the GradeCard props) onto the GradeResultCard data contract from the
|
||||
design spec. Plain CommonJS so the .tsx card imports it (allowJs) AND
|
||||
Jest exercises the mapping logic directly (no TS/Babel transform).
|
||||
============================================================ */
|
||||
|
||||
const STAT_LABELS = {
|
||||
points: 'Points', rebounds: 'Rebounds', assists: 'Assists', threes: '3-Pointers',
|
||||
steals: 'Steals', blocks: 'Blocks', pra: 'P+R+A', turnovers: 'Turnovers',
|
||||
strikeouts: 'Strikeouts', hits_allowed: 'Hits Allowed', earned_runs: 'Earned Runs',
|
||||
innings_pitched: 'Innings Pitched', hits: 'Hits', total_bases: 'Total Bases',
|
||||
rbi: 'RBI', runs: 'Runs', home_runs: 'Home Runs',
|
||||
};
|
||||
|
||||
/** Humanize a stat_type id ("home_runs" → "Home Runs"). */
|
||||
function statLabel(stat) {
|
||||
if (!stat) return '';
|
||||
if (STAT_LABELS[stat]) return STAT_LABELS[stat];
|
||||
return String(stat).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Signed edge as a percentage of the line, from projection vs line. */
|
||||
function computeEdge(projection, line, direction) {
|
||||
if (projection == null || line == null) return 0;
|
||||
const raw = direction === 'under' ? line - projection : projection - line;
|
||||
const pct = line > 0 ? (raw / line) * 100 : raw;
|
||||
return Math.round(pct * 10) / 10;
|
||||
}
|
||||
|
||||
/** A/A+ with strong support → "phosphor confirmed". */
|
||||
function isPhosphorConfirmed(grade, confidence, sampleSize) {
|
||||
const g = String(grade || '').trim().toUpperCase();
|
||||
const strong = g === 'A+' || g === 'A';
|
||||
return strong && ((confidence != null && confidence >= 70) || (sampleSize != null && sampleSize >= 30));
|
||||
}
|
||||
|
||||
/** factors object/array → plain-English signal bullets (4–6). */
|
||||
function toSignals(factors) {
|
||||
if (!factors) return [];
|
||||
if (Array.isArray(factors)) return factors.filter(Boolean).slice(0, 6).map(String);
|
||||
return Object.entries(factors)
|
||||
.filter(([, v]) => Boolean(v))
|
||||
.slice(0, 6)
|
||||
.map(([k, v]) => `${k.replace(/_/g, ' ').replace(/\b\w/, (c) => c.toUpperCase())}: ${v}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an engine result to the GradeResultCard contract.
|
||||
* input: { player, team, sport, stat, line, direction|side, grade, projection,
|
||||
* confidence, sample_size, factors, alt_lines, kill_conditions, books, tier }
|
||||
*/
|
||||
function mapScanToGradeResult(input = {}) {
|
||||
const direction = (input.side || input.direction || 'over').toString().toLowerCase();
|
||||
const side = direction === 'under' ? 'Under' : 'Over';
|
||||
const line = input.line != null ? Number(input.line) : 0;
|
||||
const projection = input.projection != null ? Number(input.projection) : undefined;
|
||||
const confidence = input.confidence != null ? Math.round(Number(input.confidence)) : 0;
|
||||
const tier = input.tier || 'free';
|
||||
const includeAlt = tier === 'desk';
|
||||
// Tier gating so the new card doesn't give paid content away (free users get
|
||||
// a 3-signal teaser; kill conditions are Analyst+; alt ladder is Desk). The
|
||||
// richer blurred-paywall treatment returns in Phase G (Session 38).
|
||||
const allSignals = toSignals(input.factors);
|
||||
const signals = tier === 'free' ? allSignals.slice(0, 3) : allSignals;
|
||||
const killConditions = tier === 'free'
|
||||
? []
|
||||
: (input.kill_conditions || []).map((k) => (typeof k === 'string' ? k : (k && k.reason) || '')).filter(Boolean);
|
||||
|
||||
return {
|
||||
player: input.player || '',
|
||||
team: input.team || '',
|
||||
sport: (input.sport || 'nba').toString().toLowerCase(),
|
||||
stat: statLabel(input.stat),
|
||||
line,
|
||||
side,
|
||||
grade: input.grade || '—',
|
||||
confidence,
|
||||
edge: computeEdge(projection, line, direction),
|
||||
projection: projection != null ? Math.round(projection * 10) / 10 : line,
|
||||
phosphorConfirmed: isPhosphorConfirmed(input.grade, input.confidence, input.sample_size),
|
||||
signals,
|
||||
killConditions,
|
||||
books: Array.isArray(input.books) ? input.books : [],
|
||||
altLadder: includeAlt && Array.isArray(input.alt_lines)
|
||||
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade }))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals };
|
||||
Reference in New Issue
Block a user