Session 26: Cross-sport tab counts, scan copy fix, game card visual polish, empty section auto-hide (1579 tests)
This commit is contained in:
@@ -4,6 +4,62 @@
|
|||||||
2026-06-12
|
2026-06-12
|
||||||
|
|
||||||
## Current Phase
|
## 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 2–3 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)
|
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
|
## Session 25 (2026-06-12) — SHIPPED
|
||||||
|
|||||||
@@ -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.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.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-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
File diff suppressed because one or more lines are too long
@@ -341,12 +341,14 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</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>}>
|
<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 ? (
|
{mostParlayed === null ? (
|
||||||
<SkeletonRow />
|
<SkeletonRow />
|
||||||
) : mostParlayed.length === 0 ? (
|
|
||||||
<p style={emptyCopy}>No parlays built yet tonight. Be the first.</p>
|
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ display: 'grid', gap: 8 }}>
|
<ul style={{ display: 'grid', gap: 8 }}>
|
||||||
{mostParlayed.map((p, i) => (
|
{mostParlayed.map((p, i) => (
|
||||||
@@ -390,6 +392,7 @@ export default function DashboardPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recent scans OR first-timer onboarding */}
|
{/* Recent scans OR first-timer onboarding */}
|
||||||
<Section title={isFirstTimer ? 'Your first read' : 'Your recent reads'} subtitle={null}>
|
<Section title={isFirstTimer ? 'Your first read' : 'Your recent reads'} subtitle={null}>
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export default function GamePage({ params }: { params: Promise<{ id: string }> }
|
|||||||
<p style={{ color: 'var(--text-tertiary)' }}>Loading props…</p>
|
<p style={{ color: 'var(--text-tertiary)' }}>Loading props…</p>
|
||||||
) : props.length === 0 ? (
|
) : props.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
<p style={{ color: 'var(--text-secondary)', fontSize: 14 }}>
|
||||||
No props posted for this game yet. Books usually open player props 2–3 hours before tip. Check back closer to game time.
|
No props posted for this game yet. Check back closer to game time.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul style={{ display: 'grid', gap: 8 }}>
|
<ul style={{ display: 'grid', gap: 8 }}>
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ export default function ScanPage() {
|
|||||||
<div style={shimmerStyle} />
|
<div style={shimmerStyle} />
|
||||||
) : games.length === 0 ? (
|
) : games.length === 0 ? (
|
||||||
<p className="surface" style={{ padding: 16, fontSize: 13, color: 'var(--text-secondary)' }}>
|
<p className="surface" style={{ padding: 16, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
No games posted yet. Books usually open player props 2–3 hours before tip.
|
No games posted yet. Check back soon.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function GameCard(props: GameCardProps) {
|
|||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
padding: '14px 16px',
|
padding: '16px 20px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
@@ -227,19 +227,20 @@ export default function GameCard(props: GameCardProps) {
|
|||||||
<div style={{ minWidth: 0, flex: 1 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
{/* Session 19 — team-abbreviation header: bold abbreviations
|
{/* Session 19 — team-abbreviation header: bold abbreviations
|
||||||
with the full names underneath in the meta line, plus a
|
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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 10,
|
gap: 11,
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
fontWeight: 700,
|
fontWeight: 800,
|
||||||
color: 'var(--text-0, #F0F0F5)',
|
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' }}>
|
<span className="mono" style={{ letterSpacing: '0.04em' }}>
|
||||||
{teamAbbr(awayTeam, sport)}
|
{teamAbbr(awayTeam, sport)}
|
||||||
</span>
|
</span>
|
||||||
@@ -333,16 +334,16 @@ export default function GameCard(props: GameCardProps) {
|
|||||||
{bookRows.length > 0 && (
|
{bookRows.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 16px',
|
padding: '12px 20px 14px',
|
||||||
borderTop: '1px solid var(--border, #1A1A24)',
|
borderTop: '1px solid var(--border, #1A1A24)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: 6,
|
gap: 8,
|
||||||
background: 'rgba(255,255,255,0.015)',
|
background: 'rgba(255,255,255,0.015)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mono"
|
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
|
Game Lines
|
||||||
</div>
|
</div>
|
||||||
@@ -350,12 +351,19 @@ export default function GameCard(props: GameCardProps) {
|
|||||||
<div
|
<div
|
||||||
key={book}
|
key={book}
|
||||||
className="mono"
|
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>
|
<span style={{ color: 'var(--text-0, #F0F0F5)', fontWeight: 700, textTransform: 'capitalize' }}>{book}</span>
|
||||||
{line.awayML && <span>{teamAbbr(awayTeam, sport)} {line.awayML}</span>}
|
<span>{line.awayML ? `${teamAbbr(awayTeam, sport)} ${line.awayML}` : '—'}</span>
|
||||||
{line.homeML && <span>{teamAbbr(homeTeam, sport)} {line.homeML}</span>}
|
<span>{line.homeML ? `${teamAbbr(homeTeam, sport)} ${line.homeML}` : '—'}</span>
|
||||||
{line.total && <span>O/U {line.total}</span>}
|
<span style={{ textAlign: 'right' }}>{line.total ? `O/U ${line.total}` : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -364,10 +372,13 @@ export default function GameCard(props: GameCardProps) {
|
|||||||
{propList.length === 0 ? (
|
{propList.length === 0 ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
padding: '20px 16px',
|
padding: '14px 20px',
|
||||||
color: 'var(--text-tertiary, #6B6B7B)',
|
color: 'var(--text-tertiary, #6B6B7B)',
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
textAlign: 'center',
|
// 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.
|
Props for this game aren't published yet.
|
||||||
@@ -427,24 +438,25 @@ export default function GameCard(props: GameCardProps) {
|
|||||||
{streakRows.length > 0 && (
|
{streakRows.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 16px',
|
padding: '12px 20px 14px',
|
||||||
borderTop: '1px solid var(--border, #1A1A24)',
|
borderTop: '1px solid var(--border, #1A1A24)',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: 6,
|
gap: 9,
|
||||||
|
background: 'linear-gradient(180deg, rgba(233,75,60,0.04) 0%, transparent 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mono"
|
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
|
🔥 Streaks
|
||||||
</div>
|
</div>
|
||||||
{streakRows.map((s) => (
|
{streakRows.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={`${s.player}-${s.description}`}
|
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.player}
|
||||||
{s.team ? <span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400 }}> · {s.team}</span> : null}
|
{s.team ? <span style={{ color: 'var(--text-tertiary, #6B6B7B)', fontWeight: 400 }}> · {s.team}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -318,6 +318,12 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
const [games, setGames] = useState<SlateGame[]>([]);
|
const [games, setGames] = useState<SlateGame[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
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
|
// Session 24 — when odds are unavailable but the schedule still has
|
||||||
// games, this becomes a soft inline notice instead of a wall-of-error.
|
// games, this becomes a soft inline notice instead of a wall-of-error.
|
||||||
const [oddsNotice, setOddsNotice] = useState(false);
|
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'.
|
// silently blank the MLB panels. Always land back on 'all'.
|
||||||
useEffect(() => { setActiveStat('all'); }, [tab]);
|
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
|
// Grading call site. Single source of truth so we never have two
|
||||||
// PropRows in-flight from the same prop (the loadingKey enforces it).
|
// PropRows in-flight from the same prop (the loadingKey enforces it).
|
||||||
const onGrade = useCallback(async (prop: PropRowProp) => {
|
const onGrade = useCallback(async (prop: PropRowProp) => {
|
||||||
@@ -496,8 +529,16 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
|
|||||||
return m;
|
return m;
|
||||||
}, [games]);
|
}, [games]);
|
||||||
const tabCount = (id: SlateTab): number | null => {
|
const tabCount = (id: SlateTab): number | null => {
|
||||||
if (id === 'all') return games.length || null;
|
if (id === 'all') {
|
||||||
return countBySport[id as SlateSport] ?? null;
|
// 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
|
// Manual scan fallback URL — pre-fills /scan with the search query
|
||||||
|
|||||||
Reference in New Issue
Block a user