80683e71b4
P0 fixes + wiring real data into the S42 Player Intelligence architecture. - P0 dropdown z-index: the nav's backdrop-filter stacking context let the Ticker/HeartbeatBar paint over the avatar/More dropdowns and eat clicks. nav now position:relative zIndex:2; menus zIndex:100. Avatar Settings -> /settings. - Real MLB stats: mlbStatsAdapter.searchPlayer + getPlayerStats (name->id-> season+gamelog). playerIntelService.resolvePlayerStats normalizes into the archetype classifier; getPlayerIntel returns found:true + real season + archetype classified from real stats. NBA via nbaStatsClient (degrades). - Game cards: slateAdapter.groupPropsByPlayer (playerStrips, name once) + mapPitchers (MLB probables), folded into mapScheduleToGameCards. Legacy GameCard line grid renders BookChip (brand colors) not grey text. - Grade card intel: analyzeViaEngine1.buildIntelFields computes stat-context + form/usage/matchup/rest from the existing feature vector (zero extra I/O); gradeAdapter lights up the card sections. Archetype deferred (needs season line at grade time). - Depth chart foundation: depthChartService (getLineup/getDepthChart/ getCascadeProjection) + /api/stats/lineup|depth|cascade, graceful + injectable. - Mobile: player hero name overflow-wrap + 24px on <=640px (was clipping). Backend 2011 -> 2045 tests (+34), 163 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
459 lines
17 KiB
TypeScript
459 lines
17 KiB
TypeScript
'use client';
|
||
|
||
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 BookChip from '@/components/vyndr/BookChip';
|
||
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.
|
||
import PlayerCard, { groupPropsByPlayer } from '@/components/PlayerCard';
|
||
|
||
/**
|
||
* GameCard — one game in the Slate (Session 13). Header with teams +
|
||
* time + venue + sport emoji; expandable list of player props
|
||
* underneath, each a PropRow.
|
||
*
|
||
* State minimalism: this component only manages "show more props"
|
||
* expansion. The graded-props Map and the "is this prop loading right
|
||
* now" boolean both live on the Slate (one source of truth for the
|
||
* grading queue).
|
||
*/
|
||
|
||
export type SlateSport = 'nba' | 'wnba' | 'mlb' | 'soccer';
|
||
|
||
const SPORT_ACCENT: Record<SlateSport, string> = {
|
||
nba: '#E94B3C',
|
||
wnba: '#FFB347',
|
||
mlb: '#1E90FF',
|
||
soccer: '#00D4A0',
|
||
};
|
||
|
||
// Session 24 — game-level book-by-book lines from Tank01. One row per
|
||
// sportsbook (bet365 / betmgm / caesars …). All fields optional/null —
|
||
// books publish lines independently, so we render only what exists.
|
||
export interface GameLineBook {
|
||
homeML?: string | null;
|
||
awayML?: string | null;
|
||
total?: string | null;
|
||
homeSpread?: string | null;
|
||
awaySpread?: string | null;
|
||
}
|
||
export interface GameLines {
|
||
homeTeam?: string | null;
|
||
awayTeam?: string | null;
|
||
books: Record<string, GameLineBook>;
|
||
}
|
||
|
||
export type GameStatus = 'pre' | 'in' | 'post';
|
||
|
||
// Session 25 — a computed streak for a player whose team is in this game.
|
||
export interface GameStreak {
|
||
player: string;
|
||
team?: string | null;
|
||
description: string;
|
||
currentStreak: number;
|
||
}
|
||
|
||
export interface GameCardProps {
|
||
sport: SlateSport;
|
||
homeTeam: string;
|
||
awayTeam: string;
|
||
gameTime?: string; // ISO timestamp — empty when status is unknown
|
||
venue?: string;
|
||
context?: string; // 'Group A · Matchday 1', 'Game 4', etc.
|
||
props: PropRowProp[];
|
||
gradedProps: Map<string, PropRowResult>;
|
||
loadingKey?: string | null; // propRowKey of the prop currently grading
|
||
errorByKey?: Record<string, string | undefined>;
|
||
tier?: Tier;
|
||
onGrade: (prop: PropRowProp) => void;
|
||
onUpgrade?: () => void;
|
||
defaultVisible?: number; // how many props to show before "+ N more"
|
||
// Session 24 — all-day intelligence layers, all optional. A game card
|
||
// shows whatever exists: schedule status/score (ESPN), game lines
|
||
// (Tank01), and props (odds-api) — nothing replaces anything else.
|
||
status?: GameStatus;
|
||
score?: { home: number; away: number } | null;
|
||
gameLines?: GameLines | null;
|
||
// Session 25 — streaks for players in THIS game, matched by team in the
|
||
// Slate. Renders inline below the props/lines so the streak context
|
||
// lives with the game it belongs to.
|
||
streaks?: GameStreak[];
|
||
}
|
||
|
||
// Map ESPN status → a compact, human badge. 'in' is live; 'post' is final.
|
||
function statusBadge(status?: GameStatus, score?: { home: number; away: number } | null) {
|
||
if (status === 'in') return { label: 'LIVE', color: '#FF4D4D', score };
|
||
if (status === 'post') return { label: 'FINAL', color: '#6B6B7B', score };
|
||
return null; // 'pre' or unknown → no badge; the tip-off time carries it
|
||
}
|
||
|
||
function formatTime(iso?: string) {
|
||
if (!iso) return '';
|
||
try {
|
||
const d = new Date(iso);
|
||
return d.toLocaleString(undefined, {
|
||
weekday: 'short', month: 'short', day: 'numeric',
|
||
hour: 'numeric', minute: '2-digit',
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Session 19 — produce an ESPN/DK-style team abbreviation from a
|
||
* full team name. The odds-api returns full names like
|
||
* "Phoenix Mercury" / "Boston Celtics"; the design calls for the
|
||
* compact city/club shorthand in the header. We hand-curate the few
|
||
* names that don't reduce well from a "take the first word"
|
||
* heuristic (St. Louis, Los Angeles, etc.) and otherwise fall back
|
||
* to the first three letters of the second word (so "Boston
|
||
* Celtics" → "BOS" and "Real Madrid" → "RM"). Soccer clubs are
|
||
* shorter and rarely follow city naming, so we just upper-case the
|
||
* full name up to 6 chars.
|
||
*/
|
||
const TEAM_ABBR_OVERRIDES: Record<string, string> = {
|
||
'Los Angeles Lakers': 'LAL',
|
||
'Los Angeles Clippers': 'LAC',
|
||
'Los Angeles Sparks': 'LAS',
|
||
'Los Angeles Angels': 'LAA',
|
||
'Los Angeles Dodgers': 'LAD',
|
||
'San Francisco Giants': 'SF',
|
||
'San Diego Padres': 'SD',
|
||
'St. Louis Cardinals': 'STL',
|
||
'New York Knicks': 'NYK',
|
||
'New York Yankees': 'NYY',
|
||
'New York Mets': 'NYM',
|
||
'New York Liberty': 'NYL',
|
||
'New Orleans Pelicans': 'NOP',
|
||
'Oklahoma City Thunder': 'OKC',
|
||
'Golden State Warriors': 'GSW',
|
||
'Portland Trail Blazers': 'POR',
|
||
'San Antonio Spurs': 'SA',
|
||
'Las Vegas Aces': 'LV',
|
||
'Washington Mystics': 'WAS',
|
||
'Washington Wizards': 'WAS',
|
||
'Washington Nationals': 'WSH',
|
||
'Tampa Bay Rays': 'TB',
|
||
'Kansas City Royals': 'KC',
|
||
'Toronto Blue Jays': 'TOR',
|
||
'Toronto Raptors': 'TOR',
|
||
'Chicago White Sox': 'CWS',
|
||
'Chicago Cubs': 'CHC',
|
||
};
|
||
|
||
export function teamAbbr(fullName: string, sport: SlateSport): string {
|
||
if (!fullName) return '';
|
||
if (TEAM_ABBR_OVERRIDES[fullName]) return TEAM_ABBR_OVERRIDES[fullName];
|
||
if (sport === 'soccer') {
|
||
// Clubs are typically one short name (Arsenal, Liverpool) or two
|
||
// ("Real Madrid"). Compose initials when there are 2+ words,
|
||
// otherwise truncate.
|
||
const words = fullName.trim().split(/\s+/);
|
||
if (words.length >= 2) return words.map((w) => w[0]).join('').toUpperCase().slice(0, 4);
|
||
return fullName.slice(0, 6).toUpperCase();
|
||
}
|
||
// US team-name pattern: "<City> <Nickname>" — take the nickname's
|
||
// first three letters (Boston Celtics → CEL → bumped to BOS via
|
||
// the override table for the well-known cases). For names we
|
||
// didn't override, the city-prefix usually carries more identity.
|
||
const words = fullName.trim().split(/\s+/);
|
||
if (words.length >= 2) {
|
||
// Two-word names: prefer the city (first word) → first 3 letters.
|
||
return words[0].slice(0, 3).toUpperCase();
|
||
}
|
||
return fullName.slice(0, 4).toUpperCase();
|
||
}
|
||
|
||
// 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 (
|
||
<div
|
||
className="mono"
|
||
style={{
|
||
padding: '7px 6px',
|
||
textAlign: 'center',
|
||
fontSize: 12.5,
|
||
fontWeight: best ? 700 : 500,
|
||
color: best ? 'var(--g-a)' : worst ? '#ff8a8a' : 'var(--text-0)',
|
||
background: best ? 'rgba(0,212,160,.13)' : worst ? 'rgba(255,82,82,.07)' : 'transparent',
|
||
borderLeft: best ? '2px solid var(--g-a)' : worst ? '2px solid rgba(255,82,82,.4)' : '2px solid transparent',
|
||
borderRadius: 3,
|
||
}}
|
||
>
|
||
{value}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function GameCard(props: GameCardProps) {
|
||
const {
|
||
sport, homeTeam, awayTeam, gameTime, venue, context,
|
||
props: propList, gradedProps, loadingKey, errorByKey,
|
||
tier = 'free', onGrade, onUpgrade,
|
||
defaultVisible = 4,
|
||
status, score, gameLines, streaks,
|
||
} = props;
|
||
const [expanded, setExpanded] = useState(false);
|
||
const badge = statusBadge(status, score);
|
||
// 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
|
||
// props. Showing the first 4 prop rows that all belonged to the
|
||
// same player meant the user only saw one player's card before
|
||
// hitting "+ more." Grouping by player first lines up the budget
|
||
// with how the UI actually reads.
|
||
const playerGroups = groupPropsByPlayer(propList);
|
||
// Stable alphabetical ordering — matches Slate's groupByGame sort.
|
||
playerGroups.sort((a, b) => a.player.localeCompare(b.player));
|
||
const visiblePlayerGroups = expanded ? playerGroups : playerGroups.slice(0, defaultVisible);
|
||
const hiddenPlayerCount = playerGroups.length - visiblePlayerGroups.length;
|
||
const accent = SPORT_ACCENT[sport];
|
||
|
||
return (
|
||
<article
|
||
className="surface"
|
||
style={{
|
||
background: 'var(--bg-2, #12121A)',
|
||
border: '1px solid var(--border, #1A1A24)',
|
||
borderRadius: 8,
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
<header
|
||
style={{
|
||
padding: '16px 20px',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'flex-start',
|
||
gap: 12,
|
||
borderLeft: `3px solid ${accent}`,
|
||
}}
|
||
>
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
{/* Session 19 — team-abbreviation header: bold abbreviations
|
||
with the full names underneath in the meta line, plus a
|
||
sport-color badge to the right. ESPN/DK pattern.
|
||
Session 26 — larger/bolder abbreviations for hierarchy. */}
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 11,
|
||
fontSize: 18,
|
||
fontWeight: 800,
|
||
color: 'var(--text-0, #F0F0F5)',
|
||
letterSpacing: '-0.02em',
|
||
}}
|
||
>
|
||
<SportBadge sport={sport} size="sm" />
|
||
<span className="mono" style={{ letterSpacing: '0.04em' }}>
|
||
{teamAbbr(awayTeam, sport)}
|
||
</span>
|
||
<span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400, fontSize: 13 }}>
|
||
vs
|
||
</span>
|
||
<span className="mono" style={{ letterSpacing: '0.04em' }}>
|
||
{teamAbbr(homeTeam, sport)}
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
marginTop: 4,
|
||
fontSize: 12,
|
||
color: 'var(--text-secondary, #8A8A9A)',
|
||
whiteSpace: 'nowrap',
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
}}
|
||
>
|
||
{awayTeam} <span style={{ color: 'var(--text-tertiary, #6B6B7B)' }}>@</span> {homeTeam}
|
||
</div>
|
||
<div
|
||
className="mono"
|
||
style={{
|
||
marginTop: 4,
|
||
fontSize: 11,
|
||
color: 'var(--text-tertiary, #6B6B7B)',
|
||
letterSpacing: '0.06em',
|
||
textTransform: 'uppercase',
|
||
}}
|
||
>
|
||
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
|
||
{/* Session 24 — live/final status + score (ESPN schedule). */}
|
||
{badge && (
|
||
<span
|
||
className="mono"
|
||
style={{
|
||
fontSize: 10,
|
||
fontWeight: 800,
|
||
letterSpacing: '0.08em',
|
||
color: '#0A0A0F',
|
||
background: badge.color,
|
||
padding: '3px 8px',
|
||
borderRadius: 4,
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{badge.label}{badge.score ? ` · ${badge.score.away}–${badge.score.home}` : ''}
|
||
</span>
|
||
)}
|
||
<div
|
||
className="mono"
|
||
style={{
|
||
fontSize: 10,
|
||
color: 'var(--text-tertiary, #6B6B7B)',
|
||
background: 'rgba(255,255,255,0.04)',
|
||
padding: '4px 8px',
|
||
borderRadius: 999,
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{propList.length} prop{propList.length === 1 ? '' : 's'}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Session 24 — game lines strip (Tank01). Book-by-book moneyline,
|
||
spread, total. Renders only when lines exist; never blocks the
|
||
card. The brand edge is props, but lines give immediate action
|
||
even when props haven't been published. */}
|
||
{lineRows.length > 0 && (
|
||
<div
|
||
style={{
|
||
padding: '12px 20px 14px',
|
||
borderTop: '1px solid var(--border, #1A1A24)',
|
||
background: 'rgba(255,255,255,0.015)',
|
||
}}
|
||
>
|
||
<SectionHead style={{ marginBottom: 10 }}>
|
||
GAME LINES <span style={{ color: 'var(--text-2)' }}>· {lineRows.length} BOOK{lineRows.length === 1 ? '' : 'S'}</span>
|
||
</SectionHead>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '72px 1fr 1fr 1fr', gap: '2px 6px', alignItems: 'center' }}>
|
||
<div className="label" style={{ fontSize: 10 }}>BOOK</div>
|
||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(awayTeam, sport)} ML</div>
|
||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>{teamAbbr(homeTeam, sport)} ML</div>
|
||
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
|
||
{lineRows.map((r) => (
|
||
<span key={r.book} style={{ display: 'contents' }}>
|
||
{/* Session 43 — brand-colored book chip (was plain grey text). */}
|
||
<div style={{ paddingLeft: 2, alignSelf: 'center' }}><BookChip book={r.book} size="sm" showName /></div>
|
||
<LineCell value={r.awayML} best={r.bestAway} worst={r.worstAway} />
|
||
<LineCell value={r.homeML} best={r.bestHome} worst={r.worstHome} />
|
||
<LineCell value={r.ou} />
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{propList.length === 0 ? (
|
||
<p
|
||
style={{
|
||
padding: '14px 20px',
|
||
color: 'var(--text-tertiary, #6B6B7B)',
|
||
fontSize: 12,
|
||
// Subtle, informational — not an error. Left-aligned so it
|
||
// reads as a quiet status line, not a centered empty wall.
|
||
textAlign: 'left',
|
||
opacity: 0.8,
|
||
}}
|
||
>
|
||
Props for this game aren't published yet.
|
||
</p>
|
||
) : (
|
||
<div>
|
||
{visiblePlayerGroups.map((g) => (
|
||
<PlayerCard
|
||
key={g.player}
|
||
player={g.player}
|
||
sport={sport}
|
||
// Per-player team would require a roster lookup that
|
||
// the current odds endpoints don't expose. Leave team
|
||
// empty — the game card header already shows both
|
||
// teams, so the user has the context.
|
||
team={undefined}
|
||
props={g.props}
|
||
gradedProps={gradedProps}
|
||
loadingKey={loadingKey}
|
||
errorByKey={errorByKey}
|
||
tier={tier}
|
||
onGrade={onGrade}
|
||
onUpgrade={onUpgrade}
|
||
/>
|
||
))}
|
||
{hiddenPlayerCount > 0 && (
|
||
<div
|
||
style={{
|
||
borderTop: '1px solid var(--border, #1A1A24)',
|
||
padding: '10px 16px',
|
||
textAlign: 'center',
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded(true)}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 0,
|
||
cursor: 'pointer',
|
||
color: 'var(--grade-a, #00D4A0)',
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
}}
|
||
>
|
||
+ {hiddenPlayerCount} more player{hiddenPlayerCount === 1 ? '' : 's'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Session 25 — streaks for players in THIS game, inline. The Slate
|
||
matches streaks to games by team, so a card shows the streak
|
||
context for the players actually on the floor/field. Renders
|
||
only when there's at least one — no empty section. */}
|
||
{streakRows.length > 0 && (
|
||
<div
|
||
style={{
|
||
padding: '12px 20px 14px',
|
||
borderTop: '1px solid var(--border, #1A1A24)',
|
||
display: 'grid',
|
||
gap: 9,
|
||
background: 'linear-gradient(180deg, rgba(233,75,60,0.04) 0%, transparent 100%)',
|
||
}}
|
||
>
|
||
<div
|
||
className="mono"
|
||
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--accent, #E94B3C)', textTransform: 'uppercase' }}
|
||
>
|
||
🔥 Streaks
|
||
</div>
|
||
{streakRows.map((s) => (
|
||
<div
|
||
key={`${s.player}-${s.description}`}
|
||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12.5, alignItems: 'baseline' }}
|
||
>
|
||
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 700 }}>
|
||
{s.player}
|
||
{s.team ? <span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400 }}> · {s.team}</span> : null}
|
||
</span>
|
||
<span style={{ color: 'var(--text-secondary, #8A8A9A)', textAlign: 'right' }}>{s.description}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|