Files
vyndr/web/src/lib/teamMeta.js
T
builtbykev 7d6dbc6cf3 Wave 2B: sportsbook wordmarks + team-logo coverage
MISSION 1 — real sportsbook wordmarks (kills lowercase "betmgm"):
- books.js: add the 6 missing ALLOWED_BOOKS keys (fanatics/bet365/
  hardrockbet/betrivers/pointsbet/pinnacle) with real brand names +
  colors — no live book falls to neutral gray. Add `slug` fields +
  bookSlug()/hasBookSvg() + BUNDLED_BOOK_SVGS.
- Bundle 8 self-authored styled-text wordmark SVGs under
  web/public/books/{slug}.svg (draftkings/fanduel/betmgm/caesars/
  bet365/pinnacle/hardrockbet/betrivers). NOT copied trademarked logo
  glyphs — the book's NAME in brand weight+color; official press-kit
  art can drop into the same paths with zero code change.
- BookWordmark: render the local SVG when bundled, else the brand-color
  styled-text fallback (never a broken image; never a lowercase key).
- Import BookWordmark into the ledger row (page.tsx:368) + the identical
  public-profile row, replacing bare {row.book} text. vyndr/GameCard
  line-grid book cell now proper-cases via bookInfo().name (keeps the
  preferred-book green highlight).

MISSION 2 — team-logo coverage gaps:
- teamMeta.js: add ESPN-schedule ball-sport abbr aliases the feed emits
  that fell to monograms — SA→SAS, NY→NYK, WSH→WAS, BRK→BKN (NBA),
  CONN→CON (WNBA). Real-abbr-first lookup means MLB WSH (Nationals) +
  WNBA NY (Liberty) still resolve directly; NY in MLB stays null.

Tests: new tests/unit/bookWordmark.test.js (all 10 ALLOWED_BOOKS resolve
to a real brand+non-gray color; 8 bundled SVGs exist; BookWordmark
SVG-first + no-lowercase-leak; ledger/profile import + use BookWordmark).
entityLayer.test.js extended for the new aliases. Full suite green
(239 suites / 2891 tests); next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:44:15 -04:00

254 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
DS0 (Design v2) — THE ENTITY LAYER: team logos + real colors.
Teams render as THEMSELVES (DESIGN-SPEC Part 2 / Part 0 law #4): real
logo + team-colored accent, never a bare "MIL @ PIT" string.
Colors + abbrs are stable public facts (like teams.js's names), so a
static registry is honest and zero-latency — no runtime fetch, no paid
dependency. Colors sourced once from ESPN's team API; logos from ESPN's
free CDN (verified 200 image/png). CommonJS so Jest requires it and the
.tsx components import it (allowJs).
Each entry: { name, espn (logo-CDN abbr), primary, secondary }. Keyed by
the app/statsapi abbr; resolveTeam() also matches full names + nicknames.
The ESPN "primary" color is often near-black (dark-theme invisible), so
accentColor() picks the more VISIBLE of the two against #06060B.
============================================================ */
// abbr → meta. MLB abbrs match statsapi; where ESPN's logo abbr differs
// (AZ→ari, CWS→chw) the `espn` field carries the CDN spelling.
const MLB = {
AZ: { name: 'Arizona Diamondbacks', espn: 'ari', primary: '#aa182c', secondary: '#e3d4ad' },
ATH: { name: 'Athletics', espn: 'oak', primary: '#003831', secondary: '#efb21e' },
ATL: { name: 'Atlanta Braves', espn: 'atl', primary: '#ba0c2f', secondary: '#13274f' },
BAL: { name: 'Baltimore Orioles', espn: 'bal', primary: '#df4601', secondary: '#000000' },
BOS: { name: 'Boston Red Sox', espn: 'bos', primary: '#bd3039', secondary: '#0d2b56' },
CHC: { name: 'Chicago Cubs', espn: 'chc', primary: '#0e3386', secondary: '#cc3433' },
CWS: { name: 'Chicago White Sox', espn: 'chw', primary: '#c4ced4', secondary: '#000000' },
CIN: { name: 'Cincinnati Reds', espn: 'cin', primary: '#c6011f', secondary: '#000000' },
CLE: { name: 'Cleveland Guardians', espn: 'cle', primary: '#e31937', secondary: '#002b5c' },
COL: { name: 'Colorado Rockies', espn: 'col', primary: '#8e6cae', secondary: '#c4ced4' },
DET: { name: 'Detroit Tigers', espn: 'det', primary: '#0a2240', secondary: '#ff4713' },
HOU: { name: 'Houston Astros', espn: 'hou', primary: '#eb6e1f', secondary: '#002d62' },
KC: { name: 'Kansas City Royals', espn: 'kc', primary: '#7ab2dd', secondary: '#004687' },
LAA: { name: 'Los Angeles Angels', espn: 'laa', primary: '#ba0021', secondary: '#c4ced4' },
LAD: { name: 'Los Angeles Dodgers', espn: 'lad', primary: '#005a9c', secondary: '#ef3e42' },
MIA: { name: 'Miami Marlins', espn: 'mia', primary: '#00a3e0', secondary: '#ef3340' },
MIL: { name: 'Milwaukee Brewers', espn: 'mil', primary: '#ffc72c', secondary: '#13294b' },
MIN: { name: 'Minnesota Twins', espn: 'min', primary: '#e20e32', secondary: '#002b5c' },
NYM: { name: 'New York Mets', espn: 'nym', primary: '#ff5910', secondary: '#002d72' },
NYY: { name: 'New York Yankees', espn: 'nyy', primary: '#c4ced4', secondary: '#132448' },
PHI: { name: 'Philadelphia Phillies',espn: 'phi', primary: '#e81828', secondary: '#284898' },
PIT: { name: 'Pittsburgh Pirates', espn: 'pit', primary: '#fdb827', secondary: '#000000' },
SD: { name: 'San Diego Padres', espn: 'sd', primary: '#ffc425', secondary: '#2f241d' },
SF: { name: 'San Francisco Giants', espn: 'sf', primary: '#fd5a1e', secondary: '#000000' },
SEA: { name: 'Seattle Mariners', espn: 'sea', primary: '#0c8f8f', secondary: '#0c2c56' },
STL: { name: 'St. Louis Cardinals', espn: 'stl', primary: '#be0a14', secondary: '#0a2252' },
TB: { name: 'Tampa Bay Rays', espn: 'tb', primary: '#8fbce6', secondary: '#092c5c' },
TEX: { name: 'Texas Rangers', espn: 'tex', primary: '#c0111f', secondary: '#003278' },
TOR: { name: 'Toronto Blue Jays', espn: 'tor', primary: '#1d78cf', secondary: '#134a8e' },
WSH: { name: 'Washington Nationals', espn: 'wsh', primary: '#ab0003', secondary: '#11225b' },
};
const NBA = {
ATL: { name: 'Atlanta Hawks', espn: 'atl', primary: '#e03a3e', secondary: '#fdb927' },
BOS: { name: 'Boston Celtics', espn: 'bos', primary: '#008348', secondary: '#ffffff' },
BKN: { name: 'Brooklyn Nets', espn: 'bkn', primary: '#c4ced4', secondary: '#000000' },
CHA: { name: 'Charlotte Hornets', espn: 'cha', primary: '#00a3af', secondary: '#1d1160' },
CHI: { name: 'Chicago Bulls', espn: 'chi', primary: '#ce1141', secondary: '#000000' },
CLE: { name: 'Cleveland Cavaliers', espn: 'cle', primary: '#bc945c', secondary: '#860038' },
DAL: { name: 'Dallas Mavericks', espn: 'dal', primary: '#0064b1', secondary: '#bbc4ca' },
DEN: { name: 'Denver Nuggets', espn: 'den', primary: '#fec524', secondary: '#0e2240' },
DET: { name: 'Detroit Pistons', espn: 'det', primary: '#1d428a', secondary: '#c8102e' },
GSW: { name: 'Golden State Warriors', espn: 'gs', primary: '#fdb927', secondary: '#1d428a' },
HOU: { name: 'Houston Rockets', espn: 'hou', primary: '#ce1141', secondary: '#c4ced4' },
IND: { name: 'Indiana Pacers', espn: 'ind', primary: '#ffd520', secondary: '#0c2340' },
LAC: { name: 'LA Clippers', espn: 'lac', primary: '#c8102e', secondary: '#1d428a' },
LAL: { name: 'Los Angeles Lakers', espn: 'lal', primary: '#fdb927', secondary: '#552583' },
MEM: { name: 'Memphis Grizzlies', espn: 'mem', primary: '#5d76a9', secondary: '#12173f' },
MIA: { name: 'Miami Heat', espn: 'mia', primary: '#98002e', secondary: '#f9a01b' },
MIL: { name: 'Milwaukee Bucks', espn: 'mil', primary: '#00471b', secondary: '#eee1c6' },
MIN: { name: 'Minnesota Timberwolves', espn: 'min', primary: '#79bc43', secondary: '#266092' },
NOP: { name: 'New Orleans Pelicans', espn: 'no', primary: '#b4975a', secondary: '#0a2240' },
NYK: { name: 'New York Knicks', espn: 'ny', primary: '#f58426', secondary: '#1d428a' },
OKC: { name: 'Oklahoma City Thunder', espn: 'okc', primary: '#007ac1', secondary: '#ef3b24' },
ORL: { name: 'Orlando Magic', espn: 'orl', primary: '#0077c0', secondary: '#c4ced4' },
PHI: { name: 'Philadelphia 76ers', espn: 'phi', primary: '#1d428a', secondary: '#e01234' },
PHX: { name: 'Phoenix Suns', espn: 'phx', primary: '#e56020', secondary: '#1d1160' },
POR: { name: 'Portland Trail Blazers', espn: 'por', primary: '#e03a3e', secondary: '#c4ced4' },
SAC: { name: 'Sacramento Kings', espn: 'sac', primary: '#5a2d81', secondary: '#63727a' },
SAS: { name: 'San Antonio Spurs', espn: 'sa', primary: '#c4ced4', secondary: '#000000' },
TOR: { name: 'Toronto Raptors', espn: 'tor', primary: '#ce1141', secondary: '#000000' },
UTA: { name: 'Utah Jazz', espn: 'utah', primary: '#79a3dc', secondary: '#4e008e' },
WAS: { name: 'Washington Wizards', espn: 'wsh', primary: '#e31837', secondary: '#002b5c' },
};
const WNBA = {
ATL: { name: 'Atlanta Dream', espn: 'atl', primary: '#e31837', secondary: '#5091cc' },
CHI: { name: 'Chicago Sky', espn: 'chi', primary: '#5091cd', secondary: '#ffd520' },
CON: { name: 'Connecticut Sun', espn: 'conn', primary: '#f05023', secondary: '#0a2240' },
DAL: { name: 'Dallas Wings', espn: 'dal', primary: '#c4d600', secondary: '#002b5c' },
GSV: { name: 'Golden State Valkyries', espn: 'gs', primary: '#b38fcf', secondary: '#000000' },
IND: { name: 'Indiana Fever', espn: 'ind', primary: '#e03a3e', secondary: '#002d62' },
LV: { name: 'Las Vegas Aces', espn: 'lv', primary: '#a7a8aa', secondary: '#000000' },
LA: { name: 'Los Angeles Sparks', espn: 'la', primary: '#fdb927', secondary: '#552583' },
MIN: { name: 'Minnesota Lynx', espn: 'min', primary: '#79bc43', secondary: '#266092' },
NY: { name: 'New York Liberty', espn: 'ny', primary: '#86cebc', secondary: '#000000' },
PHX: { name: 'Phoenix Mercury', espn: 'phx', primary: '#fa4b0a', secondary: '#3c286e' },
SEA: { name: 'Seattle Storm', espn: 'sea', primary: '#2c5235', secondary: '#fee11a' },
WAS: { name: 'Washington Mystics', espn: 'wsh', primary: '#e03a3e', secondary: '#002b5c' },
};
// Soccer = World Cup national teams (fifa.world). Keyed by the ESPN country
// code (uppercased); the flag CDN is a different path (countries/, not
// teamlogos/soccer). Colors + codes from ESPN's fifa.world API. National
// teams ARE their flag — that's the crest.
const SOCCER = {
ALG: { name: 'Algeria', espn: 'alg', primary: '#4f9a44', secondary: '#ffffff' },
ARG: { name: 'Argentina', espn: 'arg', primary: '#74acdf', secondary: '#173e69' },
AUS: { name: 'Australia', espn: 'aus', primary: '#ffcd00', secondary: '#00843d' },
AUT: { name: 'Austria', espn: 'aut', primary: '#d72b2c', secondary: '#000000' },
BEL: { name: 'Belgium', espn: 'bel', primary: '#e30613', secondary: '#6ecff6' },
BIH: { name: 'Bosnia-Herzegovina', espn: 'bih', primary: '#112855', secondary: '#ffce00' },
BRA: { name: 'Brazil', espn: 'bra', primary: '#fee000', secondary: '#193375' },
CAN: { name: 'Canada', espn: 'can', primary: '#ed2224', secondary: '#ffffff' },
CPV: { name: 'Cape Verde', espn: 'cpv', primary: '#0537e4', secondary: '#ef3340' },
COL: { name: 'Colombia', espn: 'col', primary: '#fbd632', secondary: '#21418c' },
RDC: { name: 'Congo DR', espn: 'rdc', primary: '#418fde', secondary: '#c60000' },
CRO: { name: 'Croatia', espn: 'cro', primary: '#ff0000', secondary: '#0c2fff' },
CUW: { name: 'Curaçao', espn: '11678', primary: '#0537e4', secondary: '#ffce00' },
CZE: { name: 'Czechia', espn: 'cze', primary: '#d7141a', secondary: '#11457e' },
ECU: { name: 'Ecuador', espn: 'ecu', primary: '#ffdd00', secondary: '#034ea2' },
EGY: { name: 'Egypt', espn: 'egy', primary: '#d20300', secondary: '#ffffff' },
ENG: { name: 'England', espn: 'eng', primary: '#ea1f29', secondary: '#ffffff' },
FRA: { name: 'France', espn: 'fra', primary: '#3a6dd8', secondary: '#ef4135' },
GER: { name: 'Germany', espn: 'ger', primary: '#ffce00', secondary: '#dd0000' },
GHA: { name: 'Ghana', espn: 'gha', primary: '#fbd632', secondary: '#006b3f' },
HAI: { name: 'Haiti', espn: 'hai', primary: '#0033a0', secondary: '#d21034' },
IRN: { name: 'Iran', espn: 'irn', primary: '#da0000', secondary: '#239f40' },
IRQ: { name: 'Iraq', espn: 'irq', primary: '#0a4d2e', secondary: '#ce1126' },
CIV: { name: 'Ivory Coast', espn: 'civ', primary: '#ff8200', secondary: '#009e60' },
JPN: { name: 'Japan', espn: 'jpn', primary: '#3a6dd8', secondary: '#bc002d' },
JOR: { name: 'Jordan', espn: 'jor', primary: '#e70000', secondary: '#007a3d' },
MEX: { name: 'Mexico', espn: 'mex', primary: '#00843d', secondary: '#ce1126' },
MAR: { name: 'Morocco', espn: 'mar', primary: '#df2027', secondary: '#006233' },
NED: { name: 'Netherlands', espn: 'ned', primary: '#fb5d00', secondary: '#21468b' },
NZL: { name: 'New Zealand', espn: 'nzl', primary: '#c8c8c8', secondary: '#000000' },
NOR: { name: 'Norway', espn: 'nor', primary: '#c8102e', secondary: '#00205b' },
PAN: { name: 'Panama', espn: 'pan', primary: '#d21034', secondary: '#005293' },
PAR: { name: 'Paraguay', espn: 'par', primary: '#ea2300', secondary: '#21418c' },
POR: { name: 'Portugal', espn: 'por', primary: '#da291c', secondary: '#0d6938' },
QAT: { name: 'Qatar', espn: 'qat', primary: '#8a1538', secondary: '#ffffff' },
KSA: { name: 'Saudi Arabia', espn: 'ksa', primary: '#0f7a3d', secondary: '#ffffff' },
SCO: { name: 'Scotland', espn: 'sco', primary: '#4b6cb7', secondary: '#1a2d69' },
SEN: { name: 'Senegal', espn: 'sen', primary: '#00853f', secondary: '#fdef42' },
RSA: { name: 'South Africa', espn: 'rsa', primary: '#ffb81c', secondary: '#0c562e' },
KOR: { name: 'South Korea', espn: 'kors', primary: '#ce2028', secondary: '#003478' },
ESP: { name: 'Spain', espn: 'esp', primary: '#c60b1e', secondary: '#ffc400' },
SWE: { name: 'Sweden', espn: 'swe', primary: '#fecb00', secondary: '#006aa7' },
SUI: { name: 'Switzerland', espn: 'sui', primary: '#ff0000', secondary: '#ffffff' },
TUN: { name: 'Tunisia', espn: 'tun', primary: '#d20300', secondary: '#ffffff' },
TUR: { name: 'Türkiye', espn: 'tur', primary: '#ef3340', secondary: '#ffffff' },
USA: { name: 'United States', espn: 'usa', primary: '#3a6dd8', secondary: '#d42339' },
URU: { name: 'Uruguay', espn: 'uru', primary: '#55b5e5', secondary: '#00205b' },
UZB: { name: 'Uzbekistan', espn: 'uzb', primary: '#0072ce', secondary: '#1eb53a' },
};
const LEAGUES = { mlb: MLB, nba: NBA, wnba: WNBA, soccer: SOCCER };
// Common naming variants the odds feeds use → the canonical soccer key.
const SOCCER_ALIAS = {
usa: 'USA', unitedstates: 'USA', usmnt: 'USA',
southkorea: 'KOR', koreademocraticpeoplesrepublic: 'KOR', kore : 'KOR', korearepublic: 'KOR',
ivorycoast: 'CIV', cotedivoire: 'CIV',
turkey: 'TUR', turkiye: 'TUR',
czechrepublic: 'CZE', czechia: 'CZE',
bosnia: 'BIH', bosniaandherzegovina: 'BIH',
congodr: 'RDC', drcongo: 'RDC',
curacao: 'CUW',
};
// Full-name + nickname → abbr, built once per league for resolveTeam().
const NAME_INDEX = {};
for (const [sp, table] of Object.entries(LEAGUES)) {
NAME_INDEX[sp] = {};
for (const [abbr, meta] of Object.entries(table)) {
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
NAME_INDEX[sp][norm(meta.name)] = abbr;
// nickname = last word ("Yankees", "Red Sox" → "redsox")
const parts = meta.name.split(/\s+/);
NAME_INDEX[sp][norm(parts[parts.length - 1])] = abbr;
// two-word nickname ("Red Sox", "Blue Jays", "White Sox")
if (parts.length >= 2) NAME_INDEX[sp][norm(parts.slice(-2).join(''))] = abbr;
}
}
const normKey = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
/** Resolve any team key (abbr, full name, nickname) + sport → meta or null. */
function resolveTeam(key, sport) {
if (!key) return null;
const sp = String(sport || 'mlb').toLowerCase();
const table = LEAGUES[sp];
if (!table) return null;
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
// 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)];
return { abbr: a, sport: sp, ...table[a] };
}
const byName = NAME_INDEX[sp][normKey(key)];
if (byName) return { abbr: byName, sport: sp, ...table[byName] };
return null;
}
/** ESPN free logo CDN url for a team, or null when unresolved. Soccer national
* teams use the flag CDN (countries/ for a 3-letter code, soccer/ for the rare
* numeric team id); the ball sports use teamlogos/{league}/. */
function teamLogoUrl(key, sport) {
const m = resolveTeam(key, sport);
if (!m) return null;
if (m.sport === 'soccer') {
return /^\d+$/.test(m.espn)
? `https://a.espncdn.com/i/teamlogos/soccer/500/${m.espn}.png`
: `https://a.espncdn.com/i/teamlogos/countries/500/${m.espn}.png`;
}
const path = m.sport === 'wnba' ? 'wnba' : 'nba';
return `https://a.espncdn.com/i/teamlogos/${m.sport === 'mlb' ? 'mlb' : path}/500/${m.espn}.png`;
}
// Perceived luminance of a #rrggbb (0255). Used to keep team accents
// VISIBLE on the near-black terminal bg — a #000000 primary is useless.
function luminance(hex) {
const h = String(hex || '').replace('#', '');
if (h.length !== 6) return 0;
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** The team color to use for an accent/monogram against the dark theme:
* the more visible of primary/secondary. Falls back to a neutral when
* both are near-black (never returns an invisible accent). */
function accentColor(key, sport) {
const m = resolveTeam(key, sport);
if (!m) return null;
const lp = luminance(m.primary), ls = luminance(m.secondary);
const best = lp >= ls ? m.primary : m.secondary;
return luminance(best) < 40 ? '#8A8A9A' : best; // both dark → neutral chrome
}
module.exports = { resolveTeam, teamLogoUrl, accentColor, luminance, LEAGUES, __tables: { MLB, NBA, WNBA } };