Session 19: Sports design overhaul — player cards with headshots, game card redesign, scan page tonight's players, odds diagnostic logging, tier gate utility (1444 tests)

This commit is contained in:
Kev
2026-06-12 00:30:13 -04:00
parent 0e3839a90a
commit 56392ec8f4
12 changed files with 825 additions and 41 deletions
+155 -35
View File
@@ -1,7 +1,11 @@
'use client';
import { useState } from 'react';
import PropRow, { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow';
// 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 +
@@ -60,6 +64,78 @@ function formatTime(iso?: string) {
}
}
/**
* 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();
}
const SPORT_LABEL: Record<SlateSport, string> = {
nba: 'NBA',
wnba: 'WNBA',
mlb: 'MLB',
soccer: 'SOCCER',
};
export default function GameCard(props: GameCardProps) {
const {
sport, homeTeam, awayTeam, gameTime, venue, context,
@@ -69,8 +145,16 @@ export default function GameCard(props: GameCardProps) {
} = props;
const [expanded, setExpanded] = useState(false);
const visibleProps = expanded ? propList : propList.slice(0, defaultVisible);
const hiddenCount = propList.length - visibleProps.length;
// 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 (
@@ -94,26 +178,58 @@ export default function GameCard(props: GameCardProps) {
}}
>
<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. */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 16,
fontWeight: 700,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.01em',
display: 'flex',
alignItems: 'baseline',
gap: 8,
flexWrap: 'wrap',
}}
>
<span aria-hidden style={{ fontSize: 14 }}>{SPORT_EMOJI[sport]}</span>
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{awayTeam}
<span style={{ color: 'var(--text-tertiary, #6B6B7B)', margin: '0 8px', fontWeight: 400 }}>
@
</span>
{homeTeam}
<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>
<span
className="mono"
style={{
marginLeft: 'auto',
fontSize: 10,
fontWeight: 700,
color: '#0A0A0F',
background: accent,
padding: '3px 8px',
borderRadius: 4,
letterSpacing: '0.08em',
}}
aria-label={`Sport: ${SPORT_LABEL[sport]}`}
>
{SPORT_LABEL[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"
@@ -125,7 +241,7 @@ export default function GameCard(props: GameCardProps) {
textTransform: 'uppercase',
}}
>
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ')}
{[formatTime(gameTime), venue, context].filter(Boolean).join(' · ') || ' '}
</div>
</div>
<div
@@ -155,24 +271,28 @@ export default function GameCard(props: GameCardProps) {
Props for this game aren&apos;t published yet.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{visibleProps.map((p) => {
const key = propRowKey(p);
return (
<PropRow
key={key}
prop={p}
result={gradedProps.get(key) ?? null}
loading={loadingKey === key}
error={errorByKey?.[key] ?? null}
tier={tier}
onRead={onGrade}
onUpgrade={onUpgrade}
/>
);
})}
{hiddenCount > 0 && (
<li
<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',
@@ -191,11 +311,11 @@ export default function GameCard(props: GameCardProps) {
fontWeight: 600,
}}
>
+ {hiddenCount} more prop{hiddenCount === 1 ? '' : 's'}
+ {hiddenPlayerCount} more player{hiddenPlayerCount === 1 ? '' : 's'}
</button>
</li>
</div>
)}
</ul>
</div>
)}
</article>
);
+173
View File
@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import PropRow, { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { getHeadshotUrl, PLAYER_SILHOUETTE, HeadshotSport } from '@/lib/playerHeadshot';
/**
* PlayerCard — one player and all their props (Session 19).
*
* Slate redesign: previously each prop was an independent row, so a
* player with 4 props (Pts/Reb/Ast/3PT) appeared as 4 stripes that
* repeated the player's name. That reads like a spreadsheet, not a
* sports product. PlayerCard groups: a header with headshot + name +
* team sits above N PropRow children.
*
* Headshot resolution lives in `lib/playerHeadshot.ts`. The `<img>`
* fall-through swaps to the bundled silhouette if the CDN 404s on a
* player whose league hasn't published a headshot yet.
*/
export interface PlayerCardProps {
player: string;
sport: HeadshotSport;
team?: string;
position?: string;
/** League ID — NBA stats.com ID, WNBA player ID, or MLB people ID. */
playerId?: string | number | null;
/** ESPN ID fallback (used when no league ID is known). */
espnId?: string | number | null;
/** Pre-cached headshot URL (soccer / API-Football). */
photoUrl?: string | null;
props: PropRowProp[];
gradedProps: Map<string, PropRowResult>;
loadingKey?: string | null;
errorByKey?: Record<string, string | undefined>;
tier?: Tier;
onGrade: (prop: PropRowProp) => void;
onUpgrade?: () => void;
}
export default function PlayerCard(props: PlayerCardProps) {
const {
player, sport, team, position, playerId, espnId, photoUrl,
props: propList, gradedProps, loadingKey, errorByKey,
tier = 'free', onGrade, onUpgrade,
} = props;
// Compute the initial headshot URL — onError swaps to silhouette.
const initialUrl = getHeadshotUrl({
sport,
playerId,
espnId,
cachedPhotoUrl: photoUrl,
});
const [headshotSrc, setHeadshotSrc] = useState(initialUrl);
const subtitle = [team, position].filter(Boolean).join(' · ');
return (
<div
style={{
background: 'var(--bg-2, #12121A)',
borderTop: '1px solid var(--border, #1A1A24)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 16px',
background: 'rgba(255,255,255,0.02)',
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={headshotSrc}
alt={`${player} headshot`}
width={40}
height={40}
loading="lazy"
onError={() => {
if (headshotSrc !== PLAYER_SILHOUETTE) setHeadshotSrc(PLAYER_SILHOUETTE);
}}
style={{
width: 40,
height: 40,
borderRadius: '50%',
objectFit: 'cover',
background: 'var(--bg-elevated, #15151F)',
border: '1px solid var(--border, #1A1A24)',
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.005em',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{player}
</div>
{subtitle && (
<div
className="mono"
style={{
marginTop: 2,
fontSize: 11,
color: 'var(--text-tertiary, #6B6B7B)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}
>
{subtitle}
</div>
)}
</div>
<div
className="mono"
style={{
fontSize: 10,
color: 'var(--text-tertiary, #6B6B7B)',
background: 'rgba(255,255,255,0.04)',
padding: '3px 8px',
borderRadius: 999,
whiteSpace: 'nowrap',
}}
aria-label={`${propList.length} props for ${player}`}
>
{propList.length} prop{propList.length === 1 ? '' : 's'}
</div>
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{propList.map((p) => {
const key = propRowKey(p);
return (
<PropRow
key={key}
prop={p}
result={gradedProps.get(key) ?? null}
loading={loadingKey === key}
error={errorByKey?.[key] ?? null}
tier={tier}
onRead={onGrade}
onUpgrade={onUpgrade}
/>
);
})}
</ul>
</div>
);
}
/**
* groupPropsByPlayer — preserves the original prop order (first
* appearance of a player wins their slot), so the Slate's sort
* (alphabetical by player) is the actual visual sort.
*/
export function groupPropsByPlayer(propList: PropRowProp[]): Array<{ player: string; props: PropRowProp[] }> {
const byPlayer = new Map<string, PropRowProp[]>();
for (const p of propList) {
const existing = byPlayer.get(p.player);
if (existing) existing.push(p);
else byPlayer.set(p.player, [p]);
}
return Array.from(byPlayer.entries()).map(([player, props]) => ({ player, props }));
}