Session 36: Design system Phase E — remaining screens + dashboard Bloomberg lines (1853 tests)

VYNDR 2.0 conversion, Phase E. Frontend-only; zero backend changes.

- lib/slateAdapter.js: parseAmericanOdds, detectBestLines, mapScheduleToGameCards
  (best/worst line detection — the Bloomberg pattern).
- Reskinned the LEGACY GameCard's game-lines grid with best/worst highlighting +
  SportBadge, keeping inline grading intact (a wholesale swap to the display-only
  vyndr/GameCard would have deleted the slate's grading interaction).
- compare/invite/help/about: RouteStubs -> real design-system pages.
- login reskinned (scanlines, system voice, new Wordmark); pricing + ClaimMeter.

Honest scope: the full GameCard swap needs inline grading ported into the new
component first; profile/settings/blog/game-detail reskins are light/deferred.

18 new tests. Backend 1839 -> 1853, 144 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 01:04:37 -04:00
parent 1d83682cdb
commit 612f5e0b72
12 changed files with 599 additions and 96 deletions
+126
View File
@@ -0,0 +1,126 @@
/* ============================================================
VYNDR 2.0 — slate adapter (§7, §E.1).
Merges schedule + gamelines + streaks + grades into the GameCard
contract, and detects the best/worst book line per game (the
Bloomberg pattern — the #1 visual upgrade). Plain CommonJS so the
.tsx cards import it (allowJs) AND Jest exercises the logic directly.
============================================================ */
/** American odds → decimal payout multiplier (higher = better for the bettor).
* "+150" → 2.5, "-110" → ~1.909. Returns null when unparseable. */
function parseAmericanOdds(odds) {
if (odds == null) return null;
const n = typeof odds === 'number' ? odds : parseInt(String(odds).replace(/[^\d+-]/g, ''), 10);
if (!Number.isFinite(n) || n === 0) return null;
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
}
/** Mark the most/least favorable moneyline per side across a game's books.
* Input: { book1: { awayML, homeML, total }, ... } → rows with best/worst flags.
* best/worst only set when ≥2 books disagree (a lone price isn't "best"). */
function detectBestLines(books) {
const entries = Object.entries(books || {});
const rows = entries.map(([book, ln]) => ({
book,
awayML: ln.awayML || '—',
homeML: ln.homeML || '—',
ou: ln.total != null ? `O/U ${ln.total}` : '—',
_away: parseAmericanOdds(ln.awayML),
_home: parseAmericanOdds(ln.homeML),
}));
const mark = (side) => {
const vals = rows.map((r) => r[side]).filter((v) => v != null);
if (vals.length < 2) return [null, null];
const max = Math.max(...vals);
const min = Math.min(...vals);
return max === min ? [null, null] : [max, min];
};
const [bestAway, worstAway] = mark('_away');
const [bestHome, worstHome] = mark('_home');
return rows.map((r) => ({
book: r.book,
awayML: r.awayML,
homeML: r.homeML,
ou: r.ou,
bestAway: r._away != null && r._away === bestAway,
worstAway: r._away != null && r._away === worstAway,
bestHome: r._home != null && r._home === bestHome,
worstHome: r._home != null && r._home === worstHome,
}));
}
function formatGameTime(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(undefined, { weekday: 'short', hour: 'numeric', minute: '2-digit' });
} catch {
return String(iso);
}
}
/** Map one schedule game's lines entry → the GameCard `lines[]` contract. */
function mapGameLines(linesEntry) {
if (!linesEntry || !linesEntry.books) return [];
return detectBestLines(linesEntry.books);
}
/**
* Map schedule + gamelines + streaks (+ optional grades) → GameCardData[]
* (§7). Pure — no API calls, no side effects.
*/
function mapScheduleToGameCards(schedule, gamelines, streaks, grades) {
const sched = Array.isArray(schedule) ? schedule : [];
return sched.map((g) => {
const id = g.id || `${g.awayTeam?.abbreviation || '?'}-${g.homeTeam?.abbreviation || '?'}`;
const live = g.live === true || g.status === 'in';
const linesEntry = gamelines && gamelines[id];
return {
id,
sport: (g.sport || 'nba').toLowerCase(),
live,
score: g.score ? { away: g.score.away, home: g.score.home } : undefined,
clock: g.clock || undefined,
away: { abbr: g.awayTeam?.abbreviation || '', name: g.awayTeam?.name || '' },
home: { abbr: g.homeTeam?.abbreviation || '', name: g.homeTeam?.name || '' },
time: formatGameTime(g.gameTime),
venue: g.venue || undefined,
lines: mapGameLines(linesEntry),
props: mapGradedProps(grades, g),
streaks: mapStreaks(streaks, g),
};
});
}
function mapGradedProps(grades, game) {
if (!Array.isArray(grades)) return [];
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
return grades
.filter((p) => {
const t = (p.team || '').toUpperCase();
return !t || t === h || t === a;
})
.map((p) => ({ player: p.player, stat: p.stat, line: p.line, grade: p.grade, side: p.side || 'Over', delta: p.delta }));
}
function mapStreaks(streaks, game) {
if (!Array.isArray(streaks)) return [];
const h = (game.homeTeam?.abbreviation || '').toUpperCase();
const a = (game.awayTeam?.abbreviation || '').toUpperCase();
return streaks
.filter((s) => {
const t = (s.team || '').toUpperCase();
return t && (t === h || t === a);
})
.map((s) => ({ player: s.player, text: s.text || s.description || '' }));
}
module.exports = {
parseAmericanOdds,
detectBestLines,
mapGameLines,
mapScheduleToGameCards,
formatGameTime,
};