Session 26: Cross-sport tab counts, scan copy fix, game card visual polish, empty section auto-hide (1579 tests)

This commit is contained in:
Kev
2026-06-12 20:18:55 -04:00
parent 956cdb863a
commit f8a51cd9d0
8 changed files with 164 additions and 31 deletions
+35 -23
View File
@@ -216,7 +216,7 @@ export default function GameCard(props: GameCardProps) {
>
<header
style={{
padding: '14px 16px',
padding: '16px 20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
@@ -227,19 +227,20 @@ 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. */}
sport-color badge to the right. ESPN/DK pattern.
Session 26 — larger/bolder abbreviations for hierarchy. */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 16,
fontWeight: 700,
gap: 11,
fontSize: 18,
fontWeight: 800,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.01em',
letterSpacing: '-0.02em',
}}
>
<span aria-hidden style={{ fontSize: 14 }}>{SPORT_EMOJI[sport]}</span>
<span aria-hidden style={{ fontSize: 15 }}>{SPORT_EMOJI[sport]}</span>
<span className="mono" style={{ letterSpacing: '0.04em' }}>
{teamAbbr(awayTeam, sport)}
</span>
@@ -333,16 +334,16 @@ export default function GameCard(props: GameCardProps) {
{bookRows.length > 0 && (
<div
style={{
padding: '10px 16px',
padding: '12px 20px 14px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 6,
gap: 8,
background: 'rgba(255,255,255,0.015)',
}}
>
<div
className="mono"
style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
>
Game Lines
</div>
@@ -350,12 +351,19 @@ export default function GameCard(props: GameCardProps) {
<div
key={book}
className="mono"
style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--text-secondary, #8A8A9A)', flexWrap: 'wrap' }}
style={{
display: 'grid',
gridTemplateColumns: '72px 1fr 1fr 1fr',
gap: 8,
fontSize: 11.5,
alignItems: 'baseline',
color: 'var(--text-secondary, #8A8A9A)',
}}
>
<span style={{ minWidth: 64, color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
{line.awayML && <span>{teamAbbr(awayTeam, sport)} {line.awayML}</span>}
{line.homeML && <span>{teamAbbr(homeTeam, sport)} {line.homeML}</span>}
{line.total && <span>O/U {line.total}</span>}
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
<span>{line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : '—'}</span>
<span>{line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : '—'}</span>
<span style={{ textAlign: 'right' }}>{line.total ? `O/U ${line.total}` : '—'}</span>
</div>
))}
</div>
@@ -364,10 +372,13 @@ export default function GameCard(props: GameCardProps) {
{propList.length === 0 ? (
<p
style={{
padding: '20px 16px',
padding: '14px 20px',
color: 'var(--text-tertiary, #6B6B7B)',
fontSize: 13,
textAlign: 'center',
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&apos;t published yet.
@@ -427,24 +438,25 @@ export default function GameCard(props: GameCardProps) {
{streakRows.length > 0 && (
<div
style={{
padding: '10px 16px',
padding: '12px 20px 14px',
borderTop: '1px solid var(--border, #1A1A24)',
display: 'grid',
gap: 6,
gap: 9,
background: 'linear-gradient(180deg, rgba(233,75,60,0.04) 0%, transparent 100%)',
}}
>
<div
className="mono"
style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-tertiary, #6B6B7B)', textTransform: 'uppercase' }}
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, alignItems: 'baseline' }}
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: 12.5, alignItems: 'baseline' }}
>
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 600 }}>
<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>
+43 -2
View File
@@ -318,6 +318,12 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
const [games, setGames] = useState<SlateGame[]>([]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
// Session 26 — per-sport schedule counts for the tab labels, fetched
// ONCE on mount for every schedule-backed sport (free ESPN, cached 60s).
// This makes "MLB (15)" / "WNBA (2)" show on their tabs even while the
// user is viewing a different sport — the count was previously only
// known for sports loaded by the active tab.
const [scheduleCounts, setScheduleCounts] = useState<Partial<Record<SlateSport, number>>>({});
// Session 24 — when odds are unavailable but the schedule still has
// games, this becomes a soft inline notice instead of a wall-of-error.
const [oddsNotice, setOddsNotice] = useState(false);
@@ -417,6 +423,33 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// silently blank the MLB panels. Always land back on 'all'.
useEffect(() => { setActiveStat('all'); }, [tab]);
// Session 26 — fetch schedule counts for every schedule-backed sport
// once on mount, so all sport tabs show their game count regardless of
// which tab is active. Free + cached; failures leave the count absent.
useEffect(() => {
let cancelled = false;
const SPORTS: SlateSport[] = ['nba', 'wnba', 'mlb'];
(async () => {
const entries = await Promise.all(
SPORTS.map(async (sport) => {
try {
const r = await fetch(`/api/schedule/${sport}`, { cache: 'no-store' });
if (!r.ok) return [sport, undefined] as const;
const data = (await r.json()) as ScheduleResponse;
return [sport, Array.isArray(data?.games) ? data.games.length : undefined] as const;
} catch {
return [sport, undefined] as const;
}
}),
);
if (cancelled) return;
const next: Partial<Record<SlateSport, number>> = {};
for (const [sport, count] of entries) if (count != null) next[sport] = count;
setScheduleCounts(next);
})();
return () => { cancelled = true; };
}, []);
// Grading call site. Single source of truth so we never have two
// PropRows in-flight from the same prop (the loadingKey enforces it).
const onGrade = useCallback(async (prop: PropRowProp) => {
@@ -496,8 +529,16 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
return m;
}, [games]);
const tabCount = (id: SlateTab): number | null => {
if (id === 'all') return games.length || null;
return countBySport[id as SlateSport] ?? null;
if (id === 'all') {
// Prefer the loaded total; fall back to the sum of schedule counts
// so the ALL tab reflects every sport even before its games load.
if (games.length > 0) return games.length;
const sum = Object.values(scheduleCounts).reduce((a, b) => a + (b || 0), 0);
return sum || null;
}
// Loaded games are the most accurate (they include odds-only games);
// otherwise fall back to the mount-time schedule count.
return countBySport[id as SlateSport] ?? scheduleCounts[id as SlateSport] ?? null;
};
// Manual scan fallback URL — pre-fills /scan with the search query