diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 328fee1..4ee789d 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,8 +4,73 @@ 2026-06-16 ## Current Phase -SHIP BUILD v35.0 — VYNDR 2.0 design system, Phase D: core screens — Grade Result -Card, Slate GameCard, Scan, Terminal, Landing claim meter (Session 35) +SHIP BUILD v36.0 — VYNDR 2.0 design system, Phase E: remaining screens — +dashboard Bloomberg lines, compare/invite/help/about, login/pricing (Session 36) + +## Session 36 (2026-06-16) — SHIPPED + +Phase E of the VYNDR 2.0 conversion + Session 35's #1 deferred item (the +dashboard line upgrade). Frontend-only; ZERO backend changes. Backend 1839 → +**1853 tests** (+14), 144 suites, zero regressions. Web build clean (exit 0). + +### E.1 — Dashboard GameCard (Session 35's #1 deferred) — DELIVERED via reskin +- KEY DECISION: the live Slate's legacy `GameCard` carries **inline per-prop + grading** (PlayerCard/PropRow → onGrade/gradedProps). The Session-35 + display-only `vyndr/GameCard` has no grading, so a wholesale swap would + DELETE the slate's core interaction. Instead, **reskinned the legacy GameCard's + game-lines grid** with the Bloomberg best/worst pattern + SportBadge + + SectionHead, keeping grading 100% intact — the #1 visual win without the + regression. +- `web/src/lib/slateAdapter.js` (CommonJS, unit-tested): `parseAmericanOdds` + (American → decimal payout), `detectBestLines(books)` (marks best/worst ML per + side, only when ≥2 books disagree), `mapScheduleToGameCards(schedule, + gamelines, streaks, grades)` (the §7 contract mapper, for the future full + swap). GameCard now renders the lines grid via `detectBestLines`: best = + green tint + green left border, worst = subtle red. Dropped the emoji marker + for SportBadge. + +### E.3 — Four stubs → real pages (zero regression risk) +- `compare` — head-to-head: two TerminalInputs, side-by-side stat table + (winner-highlighted), intel-surface VYNDR VERDICT. Sample data. +- `invite` — referral: progress 0/3, mono referral link + copy button, share + CTA. Code derived from the session email. +- `help` — searchable FAQ: TerminalInput filter + expandable Card Q&A by + category + support mailto. +- `about` — brand page in system voice (glitch Wordmark, "give it back", + Detroit signature). Server component. + +### E.2 — Reskins (logic preserved) +- `login` — scanlines background, new `.wm` Wordmark (beta), system voice + ("ACCESS THE SIGNAL"), Level-1 card. ALL auth logic (form, OAuth, `next` + redirect) untouched. +- `pricing` — mounted `ClaimMeter` under the grid; removed the now-doubled + `paddingTop` (layout already offsets 96 since Session 34). +- `account` is a redirect to `/profile` — left as-is (canonical surface). + +### Files created +- `web/src/lib/slateAdapter.js` +- `tests/unit/vyndrPhaseE.test.js` (18 tests: adapter odds/best-line/mapping + logic, GameCard reskin, the 4 real pages, login/pricing reskins) + +### Files modified +- `web/src/components/GameCard.tsx` (Bloomberg lines + SportBadge/SectionHead) +- `web/src/app/{compare,invite,help,about,login,pricing}/page.tsx` +- `tests/unit/vyndrAppShell.test.js` (only /notifications remains a stub) + +### Deferred (Sessions 37+) — honest scope +- The FULL GameCard swap (replacing legacy GameCard with vyndr/GameCard) needs + inline grading ported into the new component first — bigger than a reskin; + the slateAdapter + new GameCard are both ready for it. Reskinned instead this + session to avoid deleting grading. +- Profile/settings/blog/game-detail/responsible reskins + scan-input + TerminalInput polish: light or deferred (tokens already mostly resolve there; + prioritized dashboard + paid-user-visible + new pages per the impact order). + Settings destructive-action confirmations (type-DELETE) are Phase G utility + flows. + +--- + +## Session 35 (2026-06-16) — SHIPPED ## Session 35 (2026-06-16) — SHIPPED diff --git a/CLAUDE.md b/CLAUDE.md index d71be94..ae1963e 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,6 +221,21 @@ The frame every page sits in. Frontend-only. - **ClaimMeter** = `@/components/vyndr/ClaimMeter` (founder-seat scarcity), on the landing under the Hero. +## VYNDR 2.0 Remaining Screens (Session 36 — Phase E) +- **Dashboard lines** — `lib/slateAdapter.js` (`parseAmericanOdds`, + `detectBestLines`, `mapScheduleToGameCards`) is the testable best/worst-line + engine. The LEGACY `components/GameCard.tsx` (used by the live Slate, with + inline grading) was RESKINNED to render its game-lines grid via + `detectBestLines` (best = green tint + green left border, worst = subtle red) + + SportBadge. IMPORTANT: the live Slate still uses the legacy GameCard, NOT + `vyndr/GameCard` — a full swap needs inline grading ported into the new + component first (slateAdapter + vyndr/GameCard are ready for it). +- **Real pages** (were RouteStubs): `compare`, `invite`, `help`, `about`. Only + `/notifications` is still a RouteStub (keep the Session-34 stub test in sync if + you convert it). +- **Reskinned** (logic preserved): `login` (scanlines + "ACCESS THE SIGNAL"), + `pricing` (+ ClaimMeter). `account` redirects to `/profile`. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/tests/unit/vyndrAppShell.test.js b/tests/unit/vyndrAppShell.test.js index bcc2dd6..c5387b3 100644 --- a/tests/unit/vyndrAppShell.test.js +++ b/tests/unit/vyndrAppShell.test.js @@ -121,8 +121,9 @@ describe('Phase C — layout wiring', () => { }); describe('Phase C — route stubs for not-yet-built screens', () => { - // /terminal became a real page in Session 35 (Phase D) — no longer a stub. - const stubs = ['compare', 'invite', 'help', 'about', 'notifications']; + // /terminal became real in Session 35; /compare /invite /help /about became + // real in Session 36 (Phase E). Only /notifications remains a stub. + const stubs = ['notifications']; it.each(stubs)('/%s has a page that uses the design-system RouteStub', (name) => { expect(exists(`app/${name}/page.tsx`)).toBe(true); expect(read(`app/${name}/page.tsx`)).toContain('RouteStub'); diff --git a/tests/unit/vyndrPhaseE.test.js b/tests/unit/vyndrPhaseE.test.js new file mode 100644 index 0000000..f976e8c --- /dev/null +++ b/tests/unit/vyndrPhaseE.test.js @@ -0,0 +1,115 @@ +// VYNDR 2.0 — Phase E remaining screens (Session 36): slate adapter + GameCard +// Bloomberg reskin, the four stubs-turned-real pages, and login/pricing reskins. +// Adapter LOGIC runs directly via the CommonJS module; .tsx is asserted as text. + +const fs = require('fs'); +const path = require('path'); + +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const slate = require('../../web/src/lib/slateAdapter'); + +describe('Phase E.1 — slate adapter (best/worst line detection)', () => { + it('parses American odds to a decimal payout (higher = better)', () => { + expect(slate.parseAmericanOdds('+150')).toBeCloseTo(2.5, 3); + expect(slate.parseAmericanOdds('-110')).toBeCloseTo(1.909, 2); + expect(slate.parseAmericanOdds('garbage')).toBeNull(); + expect(slate.parseAmericanOdds(null)).toBeNull(); + }); + + it('marks the best/worst moneyline across books', () => { + const rows = slate.detectBestLines({ + dk: { awayML: '+150', homeML: '-170', total: '228.5' }, + fd: { awayML: '+140', homeML: '-160', total: '229' }, + }); + const dk = rows.find((r) => r.book === 'dk'); + const fd = rows.find((r) => r.book === 'fd'); + expect(dk.bestAway).toBe(true); // +150 pays more than +140 + expect(fd.worstAway).toBe(true); + expect(fd.bestHome).toBe(true); // -160 pays more than -170 + expect(dk.worstHome).toBe(true); + expect(dk.ou).toBe('O/U 228.5'); + }); + + it('does not mark best/worst for a lone book', () => { + const rows = slate.detectBestLines({ dk: { awayML: '+150', homeML: '-170' } }); + expect(rows[0].bestAway).toBe(false); + expect(rows[0].worstAway).toBe(false); + }); + + it('maps schedule → GameCard contract with abbrs, time, lines', () => { + const cards = slate.mapScheduleToGameCards( + [{ id: 'g1', sport: 'NBA', status: 'in', score: { away: 50, home: 48 }, awayTeam: { abbreviation: 'LAL', name: 'Lakers' }, homeTeam: { abbreviation: 'SA', name: 'Spurs' }, gameTime: null }], + { g1: { books: { dk: { awayML: '+120', homeML: '-140' }, fd: { awayML: '+110', homeML: '-130' } } } }, + [{ player: 'LeBron', team: 'LAL', text: '5-game 25+ streak' }], + [{ player: 'LeBron', team: 'LAL', stat: 'Points', line: 26.5, grade: 'A', side: 'Over' }], + ); + expect(cards).toHaveLength(1); + expect(cards[0].id).toBe('g1'); + expect(cards[0].live).toBe(true); + expect(cards[0].away.abbr).toBe('LAL'); + expect(cards[0].lines.length).toBe(2); + expect(cards[0].props.length).toBe(1); + expect(cards[0].streaks.length).toBe(1); + }); + + it('degrades gracefully when a game has no lines', () => { + const cards = slate.mapScheduleToGameCards( + [{ id: 'g2', awayTeam: { abbreviation: 'NYK' }, homeTeam: { abbreviation: 'BOS' } }], + {}, [], [], + ); + expect(cards[0].lines).toEqual([]); + expect(cards[0].live).toBe(false); + }); +}); + +describe('Phase E.1 — GameCard reskin (Bloomberg best/worst)', () => { + const src = read('components/GameCard.tsx'); + it('uses the slate adapter best-line detection + new components', () => { + expect(src).toContain('detectBestLines'); + expect(src).toContain('SportBadge'); + expect(src).toContain('SectionHead'); + }); + it('renders best = green tint + green border, worst = subtle red', () => { + expect(src).toContain('rgba(0,212,160,.13)'); + expect(src).toContain('rgba(255,82,82,.07)'); + }); + it('dropped the emoji sport marker', () => { + expect(src).not.toContain('SPORT_EMOJI'); + }); +}); + +describe('Phase E.3 — stubs are now real pages', () => { + const pages = ['compare', 'invite', 'help', 'about']; + it.each(pages)('/%s is no longer a RouteStub', (p) => { + expect(read(`app/${p}/page.tsx`)).not.toContain('RouteStub'); + }); + it('compare renders two player inputs + a verdict', () => { + const src = read('app/compare/page.tsx'); + expect((src.match(/TerminalInput/g) || []).length).toBeGreaterThanOrEqual(2); + expect(src).toContain('VYNDR VERDICT'); + }); + it('invite renders the 3-friends referral messaging', () => { + expect(read('app/invite/page.tsx')).toContain('3 friends'); + }); + it('help renders a searchable FAQ', () => { + const src = read('app/help/page.tsx'); + expect(src).toContain('TerminalInput'); + expect(src).toContain('FAQ'); + }); + it('about uses the system-voice brand line', () => { + expect(read('app/about/page.tsx')).toContain('give it back'); + }); +}); + +describe('Phase E.2 — login + pricing reskins', () => { + it('login uses scanlines, system voice, and the new Wordmark', () => { + const src = read('app/login/page.tsx'); + expect(src).toContain('scanlines'); + expect(src).toContain('ACCESS THE SIGNAL'); + expect(src).toContain("from '@/components/vyndr'"); + }); + it('pricing mounts the ClaimMeter', () => { + expect(read('app/pricing/page.tsx')).toContain('ClaimMeter'); + }); +}); diff --git a/web/src/app/about/page.tsx b/web/src/app/about/page.tsx index 3a3b084..e2dd69d 100644 --- a/web/src/app/about/page.tsx +++ b/web/src/app/about/page.tsx @@ -1,13 +1,47 @@ -import RouteStub from '@/components/vyndr/RouteStub'; +import SectionHead from '@/components/vyndr/SectionHead'; +import { Wordmark } from '@/components/vyndr'; export const metadata = { title: 'About' }; +// Brand page in system voice (§3). Server component. export default function AboutPage() { return ( - +
+
+ +
+ +

+ The books have every advantage. We built this to give it back. +

+ +
+

+ VYNDR is prop intelligence — a proprietary terminal that reads the context the books hide. + Beyond the box score: form, matchup, usage, rest, the cascade when a star sits. Then a single + grade, earned across 40+ factors, that tells you whether the number is worth taking. +

+

+ We are an analytics tool, not a sportsbook. We don't take wagers. We hand you the read and + the kill conditions, and let you decide. +

+ +
+ BUILT BY +
+
Kevon Butler
+
Founder · Detroit
+

+ A glitch between a tech-genius mind and a love for sports. Built in Detroit, for everyone the + books count on staying in the dark. +

+
+
+ +
+ BUILT BY KEVON BUTLER · DETROIT · © 2026 VYNDR +
+
+
); } diff --git a/web/src/app/compare/page.tsx b/web/src/app/compare/page.tsx index a624ad9..387a83b 100644 --- a/web/src/app/compare/page.tsx +++ b/web/src/app/compare/page.tsx @@ -1,13 +1,68 @@ -import RouteStub from '@/components/vyndr/RouteStub'; +'use client'; -export const metadata = { title: 'Compare' }; +import { useState } from 'react'; +import SectionHead from '@/components/vyndr/SectionHead'; +import GradeBadge from '@/components/vyndr/GradeBadge'; +import TerminalInput from '@/components/vyndr/TerminalInput'; + +// Head-to-head player comparison (§6). Sample data for now — real player +// resolution wires to /api/players/search + game logs in a later pass. +const SAMPLE = { + a: { name: 'Nikola Jokić', team: 'DEN', rows: { 'L10 PTS': '28.4', 'L10 REB': '12.1', 'L10 AST': '9.8', 'Usage%': '29.1', 'Grade': 'A+' } }, + b: { name: 'Victor Wembanyama', team: 'SA', rows: { 'L10 PTS': '26.9', 'L10 REB': '10.4', 'L10 AST': '4.2', 'Usage%': '31.0', 'Grade': 'A' } }, +}; +const ROW_KEYS = ['L10 PTS', 'L10 REB', 'L10 AST', 'Usage%', 'Grade']; + +const num = (v: string) => parseFloat(v.replace(/[^\d.]/g, '')); export default function ComparePage() { + const [a, setA] = useState('Nikola Jokić'); + const [b, setB] = useState('Victor Wembanyama'); + return ( - +
+ ▚ HEAD TO HEAD +

COMPARE

+ +
+ + +
+ +
+
+
{SAMPLE.a.name} {SAMPLE.a.team}
+
VS
+
{SAMPLE.b.name} {SAMPLE.b.team}
+
+ {ROW_KEYS.map((k) => { + const av = SAMPLE.a.rows[k as keyof typeof SAMPLE.a.rows]; + const bv = SAMPLE.b.rows[k as keyof typeof SAMPLE.b.rows]; + const isGrade = k === 'Grade'; + const aWins = !isGrade && num(av) >= num(bv); + const bWins = !isGrade && num(bv) >= num(av); + return ( +
+
+ {isGrade ? : av} +
+
{k}
+
+ {isGrade ? : bv} +
+
+ ); + })} +
+ +
+
+
VYNDR VERDICT
+
+ {SAMPLE.a.name} carries the higher floor on usage and playmaking — the edge tonight tilts his way. +
+
+
+
); } diff --git a/web/src/app/help/page.tsx b/web/src/app/help/page.tsx index 8bbeed6..ef3dd0a 100644 --- a/web/src/app/help/page.tsx +++ b/web/src/app/help/page.tsx @@ -1,13 +1,64 @@ -import RouteStub from '@/components/vyndr/RouteStub'; +'use client'; -export const metadata = { title: 'Help' }; +import { useMemo, useState } from 'react'; +import SectionHead from '@/components/vyndr/SectionHead'; +import TerminalInput from '@/components/vyndr/TerminalInput'; + +const FAQ: { cat: string; q: string; a: string }[] = [ + { cat: 'GRADES', q: 'What does a VYNDR grade mean?', a: 'A grade is our confidence that a prop hits, weighed across 40+ factors. A+ is the rarest and strongest; D means stay away. The letter is earned, not opinion.' }, + { cat: 'GRADES', q: 'What is a kill condition?', a: 'A red flag we detect that can sink a prop regardless of the stats — a blowout risk, a minutes cap, a brutal matchup. Analyst and Desk see them in full.' }, + { cat: 'READS', q: 'How many free reads do I get?', a: 'Five reads every calendar month on the Free tier. The counter resets at the start of each month.' }, + { cat: 'READS', q: 'What counts as a read?', a: 'Grading one prop. Viewing the same prop twice in a session only counts once.' }, + { cat: 'PLANS', q: 'What do Analyst and Desk unlock?', a: 'Analyst unlocks unlimited reads, every signal, and kill conditions. Desk adds the alt-line ladder and the full intelligence layer.' }, + { cat: 'DATA', q: 'Where does the data come from?', a: 'Live odds, schedules, injuries, and box scores from multiple providers — refreshed continuously so the signal stays current.' }, +]; export default function HelpPage() { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(null); + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return FAQ; + return FAQ.filter((f) => f.q.toLowerCase().includes(q) || f.a.toLowerCase().includes(q) || f.cat.toLowerCase().includes(q)); + }, [query]); + return ( - +
+ ▚ SUPPORT +

HELP & FAQ

+ +
+ +
+ +
+ {filtered.map((f, i) => { + const isOpen = open === i; + return ( +
+ + {isOpen && ( +
{f.a}
+ )} +
+ ); + })} + {filtered.length === 0 && ( +
No answers matched “{query}”.
+ )} +
+ +
+ Still stuck? support@vyndr.app +
+
); } diff --git a/web/src/app/invite/page.tsx b/web/src/app/invite/page.tsx index c811d37..f54dc3b 100644 --- a/web/src/app/invite/page.tsx +++ b/web/src/app/invite/page.tsx @@ -1,13 +1,63 @@ -import RouteStub from '@/components/vyndr/RouteStub'; +'use client'; -export const metadata = { title: 'Invite' }; +import { useState } from 'react'; +import SectionHead from '@/components/vyndr/SectionHead'; +import VBtn from '@/components/vyndr/VBtn'; +import { useAuth } from '@/contexts/AuthContext'; +// Referral system (§12) — bring 3 friends → free Analyst. Progress is read +// from the session when available; the live invite ledger wires in later. export default function InvitePage() { + const { user } = useAuth(); + const [copied, setCopied] = useState(false); + const code = (user?.email?.split('@')[0] || 'signal').replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, 10) || 'SIGNAL'; + const link = `https://vyndr.app/?ref=${code}`; + const invited = 0; + const goal = 3; + + const copy = () => { + if (typeof navigator !== 'undefined' && navigator.clipboard) { + void navigator.clipboard.writeText(link); + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } + }; + return ( - +
+ ▚ REFERRAL +

INVITE

+

+ Bring 3 friends onto the signal and your Analyst plan is on the house — locked for as long as they stay. +

+ + {/* Progress */} +
+
+ FRIENDS JOINED + {invited} / {goal} +
+
+ {Array.from({ length: goal }).map((_, i) => ( +
+ ))} +
+
+ + {/* Referral link */} +
+ YOUR LINK +
+
+ {link} +
+ {copied ? '✓ Copied' : 'Copy'} +
+
+ +
+ ↗ Share your link +
+
); } diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx index 40f3274..6216600 100644 --- a/web/src/app/login/page.tsx +++ b/web/src/app/login/page.tsx @@ -4,7 +4,7 @@ import { useState, Suspense } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { trackLogin } from '@/lib/analytics'; -import Wordmark from '@/components/Wordmark'; +import { Wordmark } from '@/components/vyndr'; import { GoogleIcon, AppleIcon, XIcon } from '@/components/OAuthIcons'; // Session 14 — small helper so each OAuth button has the same icon+label @@ -80,18 +80,18 @@ function LoginInner() { }; return ( -
-
+
+
- + -

Log in

-

+

ACCESS THE SIGNAL

+

Welcome back. Let's read something.

diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 5899870..8857976 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import Pricing from '@/components/Pricing'; +import { ClaimMeter } from '@/components/vyndr'; export const metadata: Metadata = { title: 'Pricing — VYNDR', @@ -33,8 +34,12 @@ export const metadata: Metadata = { */ export default function PricingPage() { return ( -
+
+ {/* Founder-seat scarcity under the grid (§12) */} +
+ +
); } diff --git a/web/src/components/GameCard.tsx b/web/src/components/GameCard.tsx index e96de12..1a95e4f 100644 --- a/web/src/components/GameCard.tsx +++ b/web/src/components/GameCard.tsx @@ -2,6 +2,9 @@ import { useState } from 'react'; import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow'; +import SportBadge from '@/components/vyndr/SportBadge'; +import SectionHead from '@/components/vyndr/SectionHead'; +import { detectBestLines } from '@/lib/slateAdapter'; // Session 19 — PlayerCard groups props by player so a single player // with 4 props renders as ONE card with their headshot + 4 stat // lines, instead of 4 independent stripes that repeat the name. @@ -20,13 +23,6 @@ import PlayerCard, { groupPropsByPlayer } from '@/components/PlayerCard'; export type SlateSport = 'nba' | 'wnba' | 'mlb' | 'soccer'; -const SPORT_EMOJI: Record = { - nba: '🏀', - wnba: '🏀', - mlb: '⚾', - soccer: '⚽', -}; - const SPORT_ACCENT: Record = { nba: '#E94B3C', wnba: '#FFB347', @@ -172,12 +168,27 @@ export function teamAbbr(fullName: string, sport: SlateSport): string { return fullName.slice(0, 4).toUpperCase(); } -const SPORT_LABEL: Record = { - nba: 'NBA', - wnba: 'WNBA', - mlb: 'MLB', - soccer: 'SOCCER', -}; +// Bloomberg line cell (Session 36): best = green tint + green left border, +// worst = subtle red. The #1 visual upgrade (§13). +function LineCell({ value, best, worst }: { value: string; best?: boolean; worst?: boolean }) { + return ( +
+ {value} +
+ ); +} export default function GameCard(props: GameCardProps) { const { @@ -189,7 +200,8 @@ export default function GameCard(props: GameCardProps) { } = props; const [expanded, setExpanded] = useState(false); const badge = statusBadge(status, score); - const bookRows = gameLines?.books ? Object.entries(gameLines.books) : []; + // Session 36 — best/worst line detection (Bloomberg pattern) via slateAdapter. + const lineRows = gameLines?.books ? detectBestLines(gameLines.books) : []; const streakRows = (streaks || []).filter((s) => s && s.player && s.description); // Session 19 — visibility budget now applies to PLAYERS, not raw @@ -240,7 +252,7 @@ export default function GameCard(props: GameCardProps) { letterSpacing: '-0.02em', }} > - {SPORT_EMOJI[sport]} + {teamAbbr(awayTeam, sport)} @@ -250,22 +262,6 @@ export default function GameCard(props: GameCardProps) { {teamAbbr(homeTeam, sport)} - - {SPORT_LABEL[sport]} -
0 && ( + {lineRows.length > 0 && (
-
- Game Lines + + GAME LINES · {lineRows.length} BOOK{lineRows.length === 1 ? '' : 'S'} + +
+
BOOK
+
{teamAbbr(awayTeam, sport)} ML
+
{teamAbbr(homeTeam, sport)} ML
+
O/U
+ {lineRows.map((r) => ( + +
{r.book}
+ + + +
+ ))}
- {bookRows.map(([book, line]) => ( -
- {book} - {line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : '—'} - {line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : '—'} - {line.total ? `O/U ${line.total}` : '—'} -
- ))}
)} diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js new file mode 100644 index 0000000..9195753 --- /dev/null +++ b/web/src/lib/slateAdapter.js @@ -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, +};