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:
+155
-35
@@ -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'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user