diff --git a/tests/unit/bookWordmark.test.js b/tests/unit/bookWordmark.test.js new file mode 100644 index 0000000..0c6b9e4 --- /dev/null +++ b/tests/unit/bookWordmark.test.js @@ -0,0 +1,114 @@ +// Wave 2B (data train) — sportsbook wordmarks. Every book the odds feed emits +// resolves to a real brand (name + non-gray color); the ~8 major books render a +// bundled local wordmark SVG; the ledger row renders BookWordmark, never bare +// lowercase `row.book`. The SVGs are self-authored styled text wordmarks (not +// copied trademarked logos), swappable for official press-kit art later. + +const fs = require('fs'); +const path = require('path'); +const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books'); + +// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline. +const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']; +const DEFAULT_FG = '#B8BCC8'; + +const REPO = path.resolve(__dirname, '../..'); +const read = (p) => fs.readFileSync(path.join(REPO, p), 'utf8'); + +describe('bookInfo — every ALLOWED_BOOK is a real brand (no neutral-gray fall)', () => { + test.each(ALLOWED_BOOKS)('%s resolves to a real brand name + color', (key) => { + const b = bookInfo(key); + // name must be a real brand, not the raw uppercased key echoed back + expect(b.name).toBeTruthy(); + expect(b.name.toUpperCase()).not.toBe(key.toUpperCase()); + // color must not be the neutral-gray default + expect(b.fg).toBeTruthy(); + expect(b.fg.toLowerCase()).not.toBe(DEFAULT_FG.toLowerCase()); + // never a bare lowercase feed key in the display name + expect(b.name).not.toBe(key); + }); + + test('the 6 previously-missing keys now resolve (were gray)', () => { + expect(bookInfo('fanatics').name).toBe('Fanatics'); + expect(bookInfo('bet365').name).toBe('bet365'); + expect(bookInfo('hardrockbet').name).toBe('Hard Rock'); + expect(bookInfo('betrivers').name).toBe('BetRivers'); + expect(bookInfo('pointsbet').name).toBe('PointsBet'); + expect(bookInfo('pinnacle').name).toBe('Pinnacle'); + }); + + test('case-insensitive: DraftKings / DK / draftkings all resolve the same', () => { + expect(bookInfo('DraftKings').name).toBe('DraftKings'); + expect(bookInfo('DK').name).toBe('DraftKings'); + expect(bookInfo('draftkings').name).toBe('DraftKings'); + }); +}); + +describe('bundled book wordmark SVGs', () => { + const EXPECTED = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers']; + + test('BUNDLED_BOOK_SVGS is exactly the 8 major books', () => { + expect([...BUNDLED_BOOK_SVGS].sort()).toEqual([...EXPECTED].sort()); + }); + + test.each(EXPECTED)('web/public/books/%s.svg exists and is a real ', (slug) => { + const svg = read(`web/public/books/${slug}.svg`); + expect(svg).toMatch(//); + }); + + test('bookSlug resolves feed keys + codes to the bundled slug', () => { + expect(bookSlug('draftkings')).toBe('draftkings'); + expect(bookSlug('DK')).toBe('draftkings'); + expect(bookSlug('hardrockbet')).toBe('hardrockbet'); + expect(hasBookSvg('betmgm')).toBe(true); + expect(hasBookSvg('MGM')).toBe(true); + }); + + test('a book with no bundled SVG (fanatics) still has NO svg but a real name', () => { + expect(hasBookSvg('fanatics')).toBe(false); + expect(bookInfo('fanatics').name).toBe('Fanatics'); + }); + + test('unknown book → no svg, no crash', () => { + expect(hasBookSvg('zzzbook')).toBe(false); + expect(bookSlug('zzzbook')).toBeNull(); + }); +}); + +describe('BookWordmark source — SVG-first, styled-text fallback, no lowercase leak', () => { + const src = read('web/src/components/vyndr/BookWordmark.tsx'); + + test('references the local /books/{slug}.svg path', () => { + expect(src).toContain('/books/'); + expect(src).toMatch(/\$\{slug\}\.svg/); + }); + + test('resolves via the books registry (hasBookSvg + bookInfo + bookSlug)', () => { + expect(src).toContain('hasBookSvg'); + expect(src).toContain('bookInfo'); + expect(src).toContain('bookSlug'); + }); + + test('falls back to the brand NAME (never the raw lowercase key)', () => { + // the fallback renders b.name (properly-cased brand), not the raw `book` prop + expect(src).toContain('b.name'); + expect(src).not.toMatch(/>\s*\{book\}\s* { + test('ledger page imports + uses BookWordmark', () => { + const src = read('web/src/app/ledger/page.tsx'); + expect(src).toMatch(/import\s*\{[^}]*BookWordmark[^}]*\}\s*from\s*'@\/components\/vyndr'/); + expect(src).toContain(' { + const src = read('web/src/app/u/[handle]/PublicProfile.tsx'); + expect(src).toContain('BookWordmark'); + expect(src).toContain(' { expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ'); expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS'); }); + test('ESPN-schedule ball-sport abbr aliases (Wave 2B)', () => { + // NBA abbrs the ESPN schedule emits that used to fall to a monogram + expect(resolveTeam('SA', 'nba').abbr).toBe('SAS'); // Spurs + expect(resolveTeam('NY', 'nba').abbr).toBe('NYK'); // Knicks + expect(resolveTeam('WSH', 'nba').abbr).toBe('WAS'); // Wizards + expect(resolveTeam('BRK', 'nba').abbr).toBe('BKN'); // Nets + // WNBA + expect(resolveTeam('CONN', 'wnba').abbr).toBe('CON'); // Sun + expect(resolveTeam('WSH', 'wnba').abbr).toBe('WAS'); // Mystics + }); + test('global alias never shadows a real same-abbr team in another sport', () => { + // WSH is a real MLB abbr (Nationals) — must resolve directly, NOT via the + // NBA/WNBA WSH→WAS alias. + expect(resolveTeam('WSH', 'mlb').name).toBe('Washington Nationals'); + // NY is the WNBA Liberty's real abbr — direct hit, not the NBA NY→NYK alias. + expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty'); + // NY is genuinely ambiguous in MLB (NYY/NYM) → stays null, never guessed. + expect(resolveTeam('NY', 'mlb')).toBeNull(); + }); test('unknown team → null (never a fake)', () => { expect(resolveTeam('ZZZ', 'mlb')).toBeNull(); expect(resolveTeam('', 'mlb')).toBeNull(); diff --git a/web/public/books/bet365.svg b/web/public/books/bet365.svg new file mode 100644 index 0000000..73afcbe --- /dev/null +++ b/web/public/books/bet365.svg @@ -0,0 +1 @@ +bet365 diff --git a/web/public/books/betmgm.svg b/web/public/books/betmgm.svg new file mode 100644 index 0000000..211334d --- /dev/null +++ b/web/public/books/betmgm.svg @@ -0,0 +1 @@ +BetMGM diff --git a/web/public/books/betrivers.svg b/web/public/books/betrivers.svg new file mode 100644 index 0000000..0c1d4f9 --- /dev/null +++ b/web/public/books/betrivers.svg @@ -0,0 +1 @@ +BetRivers diff --git a/web/public/books/caesars.svg b/web/public/books/caesars.svg new file mode 100644 index 0000000..9c803b0 --- /dev/null +++ b/web/public/books/caesars.svg @@ -0,0 +1 @@ +Caesars diff --git a/web/public/books/draftkings.svg b/web/public/books/draftkings.svg new file mode 100644 index 0000000..ee3a8b4 --- /dev/null +++ b/web/public/books/draftkings.svg @@ -0,0 +1 @@ +DraftKings diff --git a/web/public/books/fanduel.svg b/web/public/books/fanduel.svg new file mode 100644 index 0000000..18bb053 --- /dev/null +++ b/web/public/books/fanduel.svg @@ -0,0 +1 @@ +FanDuel diff --git a/web/public/books/hardrockbet.svg b/web/public/books/hardrockbet.svg new file mode 100644 index 0000000..19d8f3a --- /dev/null +++ b/web/public/books/hardrockbet.svg @@ -0,0 +1 @@ +Hard Rock diff --git a/web/public/books/pinnacle.svg b/web/public/books/pinnacle.svg new file mode 100644 index 0000000..9bd04b2 --- /dev/null +++ b/web/public/books/pinnacle.svg @@ -0,0 +1 @@ +Pinnacle diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 579bc8d..b41528f 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; -import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr'; +import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark } from '@/components/vyndr'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** @@ -365,7 +365,7 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) { {row.side} {row.line} {row.stat.replace(/_/g, ' ')}

- {row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} + {row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {/* model_value is MODEL output — always labeled, never blended with market numbers. */} {row.model_value != null && · MODEL {row.model_value}}

diff --git a/web/src/app/u/[handle]/PublicProfile.tsx b/web/src/app/u/[handle]/PublicProfile.tsx index 03811dd..ed29f34 100644 --- a/web/src/app/u/[handle]/PublicProfile.tsx +++ b/web/src/app/u/[handle]/PublicProfile.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import BookWordmark from '@/components/vyndr/BookWordmark'; /** * PublicProfile (A1 Session 10) — the public ledger record for one handle. @@ -272,7 +273,7 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {

- {row.book || '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} + {row.book ? : '—'}{row.locked_odds ? ` · ${row.locked_odds}` : ''} · {row.game_date} {row.model_value != null && · MODEL {row.model_value}}

diff --git a/web/src/components/vyndr/BookWordmark.tsx b/web/src/components/vyndr/BookWordmark.tsx index 04628b2..7c66c9f 100644 --- a/web/src/components/vyndr/BookWordmark.tsx +++ b/web/src/components/vyndr/BookWordmark.tsx @@ -1,10 +1,16 @@ -import { bookInfo } from '@/lib/books'; +import { bookInfo, bookSlug, hasBookSvg } from '@/lib/books'; /** - * BookWordmark (DS0) — a sportsbook renders as its brand: the real name in - * the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare - * lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts - * where the BookChip tile is too heavy. `best` gives it the signal ring. + * BookWordmark (DS0 · Wave 2B) — a sportsbook renders as its brand. When a + * bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8 + * major books) it renders that; otherwise it falls back to the real book NAME + * in the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare + * lowercase "draftkings" string (DESIGN-SPEC Part 2). + * + * The bundled SVGs are self-authored styled text wordmarks — not copied + * trademarked logo glyphs — so the founder can drop official press-kit art into + * the same paths later with zero code change. The files are local + always + * present, so no broken-image state is possible. `best` gives the signal ring. */ export default function BookWordmark({ book, @@ -16,6 +22,8 @@ export default function BookWordmark({ size?: number; }) { const b = bookInfo(book); + const slug = bookSlug(book); + const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null; return ( {best && } - {b.name} + {svg ? ( + // eslint-disable-next-line @next/next/no-img-element + {b.name} + ) : ( + b.name + )} ); } diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index ef68c2a..53d8611 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -9,7 +9,7 @@ import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@ import TeamLogo from '@/components/vyndr/TeamLogo'; import { accentColor } from '@/lib/teamMeta'; import { playerHref } from '@/lib/playerHref'; -import { isPreferredBook } from '@/lib/books'; +import { isPreferredBook, bookInfo } from '@/lib/books'; import { pendingSummary, topReadForCard } from '@/lib/slateAdapter'; import { nextRunLabelET } from '@/lib/pipelineSchedule'; import { useParlay, legKey } from '@/contexts/ParlayContext'; @@ -301,7 +301,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
O/U
{g.lines.map((ln, i) => ( -
{ln.book}
+
{bookInfo(ln.book).name}
diff --git a/web/src/lib/books.js b/web/src/lib/books.js index 48a42c7..4e5f2d1 100644 --- a/web/src/lib/books.js +++ b/web/src/lib/books.js @@ -1,29 +1,69 @@ /* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html - BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */ + BOOKS table. CommonJS so it's testable + importable from the .tsx chip. + + Wave 2B (data train) — the map now covers EVERY key the odds feed emits + (`oddsNormalizer.ALLOWED_BOOKS`): draftkings, fanduel, betmgm, caesars, + fanatics, bet365, hardrockbet, pointsbet, betrivers, pinnacle. No live book + falls to the neutral-gray default. Entries carry a `slug` = the canonical + lowercase book key; `bookSlug()` resolves any input (id/code/name) to it, and + BUNDLED_BOOK_SVGS names the 8 books that have a local wordmark SVG under + `web/public/books/{slug}.svg` (self-authored styled wordmarks, swappable for + official press-kit art without a code change). */ const BOOKS = { - DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, - DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, - FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, - FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, - MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, - BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, - CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, - CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, + DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, + DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, + FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, + FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, + MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, + BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, + CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, + CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, 'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, - BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' }, + // BetRivers is a blue book (its logo is blue "BetRivers"), not purple. + BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, + BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, - FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, - B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' }, - HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, + FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, + B365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' }, + BET365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' }, + HR: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + HARDROCKBET: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + POINTSBET: { name: 'PointsBet', mono: 'PTS', slug: 'pointsbet', bg: '#1F0808', fg: '#E4344A', bd: '#E4344A55' }, + PINNACLE: { name: 'Pinnacle', mono: 'PIN', slug: 'pinnacle', bg: '#180C0E', fg: '#C8434F', bd: '#C8434F55' }, UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' }, }; +// The 8 books with a bundled local wordmark SVG (web/public/books/{slug}.svg). +// Self-authored styled text wordmarks — NOT copied trademarked logo glyphs. +const BUNDLED_BOOK_SVGS = new Set([ + 'draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers', +]); + +const DEFAULT_FG = '#B8BCC8'; + function bookInfo(book) { const key = String(book == null ? '' : book).toUpperCase(); - return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' }; + return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: DEFAULT_FG, bd: '#23232F' }; +} + +/** Canonical lowercase book slug for any input (id/code/name), or null when the + * book isn't in the registry. Used to look up the bundled wordmark SVG. */ +function bookSlug(book) { + const info = bookInfo(book); + if (info.slug) return info.slug; + // Unknown-but-nameable: derive a slug from the resolved name (still not the + // raw lowercase feed key). Only bundled slugs matter for the SVG lookup. + return null; +} + +/** Does `book` have a bundled local wordmark SVG? */ +function hasBookSvg(book) { + const slug = bookSlug(book); + return slug != null && BUNDLED_BOOK_SVGS.has(slug); } /** Canonical comparison key for a book (Session 49) — resolves "DK"/"draftkings" @@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) { return preferred.some((p) => bookKey(p) === k); } -module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook }; +module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook }; diff --git a/web/src/lib/teamMeta.js b/web/src/lib/teamMeta.js index bc230ed..27cd0bb 100644 --- a/web/src/lib/teamMeta.js +++ b/web/src/lib/teamMeta.js @@ -195,7 +195,16 @@ function resolveTeam(key, sport) { const up = String(key).toUpperCase().trim(); if (table[up]) return { abbr: up, sport: sp, ...table[up] }; // statsapi/ESPN alias fallbacks for the two MLB mismatches - const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' }; + // Global alias table. resolveTeam checks the real abbr FIRST, so an entry + // only fires for a sport whose table lacks that key — e.g. WSH resolves + // directly to the MLB Nationals, but NBA/WNBA (no WSH key) fall through to + // WAS (Wizards/Mystics). ESPN-schedule abbrs the ball sports actually emit + // (SA/NY/WSH/BRK for NBA, CONN for WNBA) are covered here. + const ALIAS = { + ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', + GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX', + SA: 'SAS', NY: 'NYK', WSH: 'WAS', BRK: 'BKN', CONN: 'CON', + }; if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] }; if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) { const a = SOCCER_ALIAS[normKey(key)];