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
+56
View File
@@ -4,6 +4,62 @@
2026-06-12
## Current Phase
SHIP BUILD v26.0 — Cross-sport tab counts, scan copy, game-card visual polish, empty-section auto-hide (Session 26)
## Session 26 (2026-06-12) — SHIPPED
Finished making every sport visible and polished the presentation. Traced
the MLB/WNBA "no count" symptom to its real cause before touching code.
Backend unchanged: **1579 tests**, 125 suites, zero regressions. Web build
clean.
### PHASE 1 — MLB/WNBA tab counts (traced)
- TRACE: hit ESPN live — MLB returns 15 events, WNBA 2, with exactly the
shape `scheduleService.normalizeEvent` expects. The backend was correct;
Session 25's proxy fix already unblocked the data flow.
- Real gap: the Slate's tab counts were derived ONLY from the active tab's
loaded games, so a sport showed no count until you clicked its tab.
- FIX: a mount-time effect fetches schedule counts for nba/wnba/mlb (free,
cached) so every tab shows "MLB (15)" / "WNBA (2)" regardless of which
tab is active. `tabCount` prefers loaded data, falls back to the count.
### PHASE 2 — Scan copy
- Removed "Books usually open player props 23 hours before tip" from
`scan/page.tsx` and `game/[id]/page.tsx` (we don't assume book timing).
Kept Features' "30 min before tip" — that's a lineup-intel claim, not a
book-line timing assumption.
### PHASE 3 — Game-card visual polish
- Header: 18px/800 abbreviations, 16×20 padding for breathing room.
- Game-lines strip: aligned 4-column grid (book · away · home · O/U) with
em-dash placeholders, more padding.
- Inline streaks: accent-colored label + subtle red gradient wash, premium.
- Empty-props line: smaller, left-aligned, dimmed — informational, not an
error wall.
- Verified StatFilterPills (filled active pill) and the Hero sport-badge
strip (active filled / coming-soon dimmed, all 9 sports) already match
the design spec; notice banner already neutral (no red).
### PHASE 4 — Empty-section auto-hide
- "Most parlayed tonight" now hides entirely when loaded-but-empty instead
of showing a "be the first" prompt (dead space on a fresh platform).
### PHASE 5 — BACKEND_URL (verified)
- All proxies (new + odds) default to `http://localhost:3000`, consistent
with `odds-cache.ts`. Prod sets `BACKEND_URL`; the new routes inherit it.
No change — deliberately kept consistent with the working odds proxies
rather than introducing a 3001 default that would diverge from them.
### Files modified
- `web/src/components/Slate.tsx` (schedule-count effect, tabCount fallback)
- `web/src/components/GameCard.tsx` (header, lines strip, streaks, empty)
- `web/src/app/scan/page.tsx`, `web/src/app/game/[id]/page.tsx` (copy)
- `web/src/app/dashboard/page.tsx` (auto-hide Most-parlayed)
---
## Previous Phase
SHIP BUILD v25.0 — Fix every data-rendering bug: the frontend now actually SHOWS the backend's data (Session 25)
## Session 25 (2026-06-12) — SHIPPED
+21
View File
@@ -717,3 +717,24 @@
{"ts":"2026-06-12T21:43:25.003Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T21:43:25.020Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T21:43:25.037Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T22:26:21.055Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T22:26:21.056Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T22:26:21.056Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T22:26:21.374Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T22:26:22.330Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T22:26:22.358Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T22:26:22.557Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T22:46:28.479Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T22:46:30.491Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T22:46:30.492Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T22:46:30.492Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T22:46:30.571Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T22:46:30.585Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T22:46:30.663Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T22:47:15.990Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-12T22:47:16.077Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-12T22:47:16.308Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-12T22:47:16.309Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-12T22:47:16.309Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-12T22:47:16.363Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-12T22:47:16.575Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -341,12 +341,14 @@ export default function DashboardPage() {
)}
</Section>
{/* Most parlayed tonight */}
{/* Most parlayed tonight — Session 26: auto-hide the whole section
once loaded with no data. An empty "be the first" prompt on a
fresh platform reads as dead space; show it only when there's
real trending activity (or while still loading). */}
{(mostParlayed === null || mostParlayed.length > 0) && (
<Section title="Most parlayed tonight" subtitle="What other bettors are stacking." right={<span className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)' }}>🔥 TRENDING</span>}>
{mostParlayed === null ? (
<SkeletonRow />
) : mostParlayed.length === 0 ? (
<p style={emptyCopy}>No parlays built yet tonight. Be the first.</p>
) : (
<ul style={{ display: 'grid', gap: 8 }}>
{mostParlayed.map((p, i) => (
@@ -390,6 +392,7 @@ export default function DashboardPage() {
</ul>
)}
</Section>
)}
{/* Recent scans OR first-timer onboarding */}
<Section title={isFirstTimer ? 'Your first read' : 'Your recent reads'} subtitle={null}>
+1 -1
View File
@@ -195,7 +195,7 @@ export default function GamePage({ params }: { params: Promise<{ id: string }> }
<p style={{ color: 'var(--text-tertiary)' }}>Loading props</p>
) : props.length === 0 ? (
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
No props posted for this game yet. Books usually open player props 23 hours before tip. Check back closer to game time.
No props posted for this game yet. Check back closer to game time.
</p>
) : (
<ul style={{ display: 'grid', gap: 8 }}>
+1 -1
View File
@@ -349,7 +349,7 @@ export default function ScanPage() {
<div style={shimmerStyle} />
) : games.length === 0 ? (
<p className="surface" style={{ padding: 16, fontSize: 13, color: 'var(--text-secondary)' }}>
No games posted yet. Books usually open player props 23 hours before tip.
No games posted yet. Check back soon.
</p>
) : (
<select
+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