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:
Kev
2026-06-16 00:20:45 -04:00
parent 907c7b17c1
commit 1d83682cdb
13 changed files with 1205 additions and 33 deletions
+92
View File
@@ -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 (46). */
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 };