diff --git a/src/routes/streaks.js b/src/routes/streaks.js index 23ea823..6cc97b6 100644 --- a/src/routes/streaks.js +++ b/src/routes/streaks.js @@ -90,9 +90,12 @@ router.get('/:sport', async (req, res) => { } const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all'; const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0; + // Session 60 (night2/C) — a player's active streaks for the dossier page. + const playerKey = req.query.player ? nameKey(String(req.query.player).slice(0, 60)) : null; try { - const roster = await loadRosterLogs(sport); + let roster = await loadRosterLogs(sport); + if (playerKey) roster = roster.filter((p) => nameKey(p.name) === playerKey); const streaks = streaksService.computeStreaks(roster, sport, { stat }); // Session 60 — form heat (hot hitters/sluggers/shooters) joins the feed. const heat = streaksService.computeFormHeat(roster, sport, {}) diff --git a/tests/unit/explorePage.test.js b/tests/unit/explorePage.test.js index e180e79..d5d750a 100644 --- a/tests/unit/explorePage.test.js +++ b/tests/unit/explorePage.test.js @@ -1,26 +1,44 @@ -// Session 42 — Stats Explorer (/explore) page (bonus design section 07). +// Session 42 — Stats Explorer; Session 60 (night2/C) — the AGGREGATOR hub. +// The page is now a server shell (SEO metadata) around the ExploreHub client +// component: leaders + full streaks + hot lists = the "entire picture" page. const fs = require('fs'); const path = require('path'); const WEB = path.join(__dirname, '..', '..', 'web', 'src'); -const src = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8'); +const hub = fs.readFileSync(path.join(WEB, 'components', 'ExploreHub.tsx'), 'utf8'); +const shell = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8'); -describe('Stats Explorer page', () => { +describe('Stats Explorer hub (client)', () => { it('consumes the real /api/stats/leaders endpoint', () => { - expect(src).toContain('/api/stats/leaders'); + expect(hub).toContain('/api/stats/leaders'); }); it('has NBA/MLB/WNBA sport tabs + a player search filter', () => { - expect(src).toContain('NBA'); - expect(src).toContain('MLB'); - expect(src).toContain('WNBA'); - expect(src).toContain('Search players'); + expect(hub).toContain('NBA'); + expect(hub).toContain('MLB'); + expect(hub).toContain('WNBA'); + expect(hub).toContain('Search players'); }); it('rows link to the player profile', () => { - expect(src).toContain('playerHref'); + expect(hub).toContain('playerHref'); }); it('handles loading / error / empty states', () => { - expect(src).toContain("'loading'"); - expect(src).toContain("'error'"); - expect(src).toContain('No graded props'); + expect(hub).toContain("'loading'"); + expect(hub).toContain("'error'"); + expect(hub).toContain('No graded props'); + }); + // Session 60 — the aggregator: full streaks + hot lists on one page, + // gated by the REAL tier (streaks free for all; hot lists top-3 free). + it('mounts the streaks + hot-list aggregator with the real tier', () => { + expect(hub).toContain(' { + it('is a server component carrying daily-indexable metadata', () => { + expect(shell).not.toContain("'use client'"); + expect(shell).toContain('export const metadata'); + expect(shell).toContain('Hit Streaks'); + expect(shell).toContain(''); }); }); diff --git a/web/src/app/explore/page.tsx b/web/src/app/explore/page.tsx index 7246a64..a54bc9f 100644 --- a/web/src/app/explore/page.tsx +++ b/web/src/app/explore/page.tsx @@ -1,128 +1,17 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import SportBadge from '@/components/vyndr/SportBadge'; -import GradeBadge from '@/components/vyndr/GradeBadge'; -import { playerHref } from '@/lib/playerHref'; +import ExploreHub from '@/components/ExploreHub'; /** - * Stats Explorer (/explore) — Session 42, design section 07. The data hub: - * tonight's league leaderboard (top graded props by confidence) with grade + - * archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels - * (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are - * a Session-43 follow-up — see BUILD-STATE. + * /explore — the aggregator hub (Session 60, night2/C). Server shell for SEO: + * daily-regenerating indexable surface (streaks, hot lists, leaders). The + * interactive hub is the client component. */ - -interface Leader { - player: string; - team: string; - stat: string; - line: number | string; - side: string; - grade: string; - confidence: number; -} - -const SPORTS = [ - { key: 'nba', label: 'NBA' }, - { key: 'mlb', label: 'MLB' }, - { key: 'wnba', label: 'WNBA' }, -]; +export const metadata = { + title: 'MLB Hit Streaks, Hot Hitters & Stat Leaders — VYNDR Explore', + description: + 'Live player streaks (hit streaks, on-base streaks, HR streaks), 7-day hot hitters and hot sluggers, and tonight\'s graded stat leaders — every number with matchup context. MLB + WNBA, updated daily.', + keywords: ['MLB hit streaks', 'hottest hitters this week', 'WNBA player streaks', 'player prop stats', 'hot hitters MLB'], +}; export default function ExplorePage() { - const [sport, setSport] = useState('nba'); - const [leaders, setLeaders] = useState([]); - const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); - const [query, setQuery] = useState(''); - - useEffect(() => { - let active = true; - setState('loading'); - fetch(`/api/stats/leaders?sport=${sport}&limit=25`) - .then((r) => r.json()) - .then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } }) - .catch(() => { if (active) setState('error'); }); - return () => { active = false; }; - }, [sport]); - - const rows = useMemo(() => { - const q = query.trim().toLowerCase(); - return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders; - }, [leaders, query]); - - return ( -
-
-
-
STATS · /EXPLORE
-

Stats Explorer

-

- Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has. -

-
-
- {SPORTS.map((s) => ( - - ))} -
-
- - {/* FILTER BAR */} -
- - setQuery(e.target.value)} - placeholder="Search players" - style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }} - /> - {rows.length} graded -
- - {/* LEADERBOARD */} -
- - LEAGUE LEADERBOARD - -
- -
-
-
#
PLAYER
PROP
CONF
GRD
-
- - {state === 'loading' &&
Loading tonight's slate…
} - {state === 'error' &&
Could not load the leaderboard. Try again.
} - {state === 'ready' && rows.length === 0 && ( -
- No graded props for {sport.toUpperCase()} yet — check back when tonight's slate posts. -
- )} - {state === 'ready' && rows.map((r, i) => ( - -
{i + 1}
-
- {r.player} - {r.team && {r.team}} -
-
{r.stat} {r.side}{r.line}
-
{r.confidence != null ? `${r.confidence}%` : '—'}
-
-
- ))} -
-
- ); + return ; } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 57de3ef..816d004 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -63,7 +63,10 @@ export default function Home() {
- + {/* Session 60 (night2/C) — the free hook: top-3 REAL streaks through + the lens. MLB is the in-season board (was nba — off-season = the + panel self-hid and the teaser never showed). */} +
diff --git a/web/src/app/player/[name]/page.tsx b/web/src/app/player/[name]/page.tsx index 7100d97..0fdb4f1 100644 --- a/web/src/app/player/[name]/page.tsx +++ b/web/src/app/player/[name]/page.tsx @@ -7,6 +7,7 @@ import GradeBadge from '@/components/vyndr/GradeBadge'; import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ModelRecord from '@/components/vyndr/ModelRecord'; +import PlayerStreaks from '@/components/vyndr/PlayerStreaks'; import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter'; interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string } @@ -134,6 +135,10 @@ export default function PlayerProfilePage() { )} + {/* Session 60 (night2/C) — active streaks join the dossier (FREE layer, + through the lens). Self-hides when the player has none. */} + + {/* B. PROP DNA */} {dna.length > 0 && (
diff --git a/web/src/components/ExploreHub.tsx b/web/src/components/ExploreHub.tsx new file mode 100644 index 0000000..04178f5 --- /dev/null +++ b/web/src/components/ExploreHub.tsx @@ -0,0 +1,139 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import SportBadge from '@/components/vyndr/SportBadge'; +import GradeBadge from '@/components/vyndr/GradeBadge'; +import { playerHref } from '@/lib/playerHref'; + +/** + * ExploreHub (Session 42 leaders; Session 60 night2/C — THE AGGREGATOR). + * The "entire picture" page: leaders + full streaks + hot lists, one sport + * selector, everything free and interpreted through the lens. The page shell + * (app/explore/page.tsx) is a server component carrying the SEO metadata. + */ +import StreaksPanel from '@/components/StreaksPanel'; +import HotListPanel from '@/components/HotListPanel'; +import { useAuth } from '@/contexts/AuthContext'; + +interface Leader { + player: string; + team: string; + stat: string; + line: number | string; + side: string; + grade: string; + confidence: number; +} + +const SPORTS = [ + { key: 'nba', label: 'NBA' }, + { key: 'mlb', label: 'MLB' }, + { key: 'wnba', label: 'WNBA' }, +]; + +export default function ExploreHub() { + const { tier } = useAuth(); + const [sport, setSport] = useState('mlb'); + const [leaders, setLeaders] = useState([]); + const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [query, setQuery] = useState(''); + + useEffect(() => { + let active = true; + setState('loading'); + fetch(`/api/stats/leaders?sport=${sport}&limit=25`) + .then((r) => r.json()) + .then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } }) + .catch(() => { if (active) setState('error'); }); + return () => { active = false; }; + }, [sport]); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders; + }, [leaders, query]); + + return ( +
+
+
+
STATS · /EXPLORE
+

Stats Explorer

+

+ Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has. +

+
+
+ {SPORTS.map((s) => ( + + ))} +
+
+ + {/* FILTER BAR */} +
+ + setQuery(e.target.value)} + placeholder="Search players" + style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }} + /> + {rows.length} graded +
+ + {/* LEADERBOARD */} +
+ + LEAGUE LEADERBOARD + +
+ +
+
+
#
PLAYER
PROP
CONF
GRD
+
+ + {state === 'loading' &&
Loading tonight's slate…
} + {state === 'error' &&
Could not load the leaderboard. Try again.
} + {state === 'ready' && rows.length === 0 && ( +
+ No graded props for {sport.toUpperCase()} yet — check back when tonight's slate posts. +
+ )} + {state === 'ready' && rows.map((r, i) => ( + +
{i + 1}
+
+ {r.player} + {r.team && {r.team}} +
+
{r.stat} {r.side}{r.line}
+
{r.confidence != null ? `${r.confidence}%` : '—'}
+
+
+ ))} +
+ + {/* Session 60 (night2/C) — THE AGGREGATOR. Full streaks + hot lists + for the selected sport, every row through the lens. The picture is + free; the grade is the paid layer. Panels self-hide when cold. */} +
+ + +
+
+ ); +} diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index 2e4def9..18913ac 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -176,7 +176,12 @@ interface PitcherResponse { games?: PitcherGame[] } type GradeIndex = ReturnType; type DeltaIndex = ReturnType; type PitcherMap = ReturnType; -function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap): GameCardData { +function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all'): GameCardData { + // Session 60 (night2/C) — ONE stat selection filters every layer: props on + // the cards narrow together with the streaks + hot-list panels below. + const props = statFilter && statFilter !== 'all' + ? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter) + : g.props; return { id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`, sport: g.sport, @@ -189,7 +194,7 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [], // Session 59 (work-order 1.6) — pass the game's participants so the join // guard can drop bad feed rows (a player whose real team isn't in this game). - playerStrips: buildPlayerStripsFromProps(g.props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }), + playerStrips: buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }), // Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers). pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined, streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })), @@ -854,7 +859,7 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook {filteredGames.map((g, i) => ( router.push('/scan')} /> diff --git a/web/src/components/StreaksPanel.tsx b/web/src/components/StreaksPanel.tsx index 43950ad..90307d9 100644 --- a/web/src/components/StreaksPanel.tsx +++ b/web/src/components/StreaksPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; -import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate'; +import { type Tier } from '@/lib/tierGate'; /** * StreaksPanel (Session 23). @@ -25,6 +25,9 @@ interface Streak { category: string; currentStreak: number; description: string; + // Session 60 (night2/C) — THE LENS + optional snapshot grade letter. + lens?: { builtVs?: string[] | null; matchup?: string | null; difficulty?: string | null; read?: string | null }; + grade?: string | null; } export interface StreaksPanelProps { @@ -56,10 +59,13 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit if (!streaks || streaks.length === 0) return null; - const tierCount = getVisibleCount(tier, streaks.length); - const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount; - const visible = streaks.slice(0, cap); - const hidden = limit ? streaks.length - visible.length : getHiddenCount(tier, streaks.length); + // Session 60 (product def) — the PICTURE is free: every streak shows for + // every tier. Only an explicit `limit` (landing teaser) caps the list. + // The paid layer is the GRADE, not the data. tierGate still governs the + // hot LISTS (top-3 free) — not streaks. + void tier; + const visible = limit && limit > 0 ? streaks.slice(0, limit) : streaks; + const hidden = streaks.length - visible.length; return (
@@ -76,16 +82,27 @@ export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit onError={(e) => { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} />
-
{s.player}{s.team ? ` · ${s.team}` : ''}
-
{s.description}
+
+ {s.player}{s.team ? ` · ${s.team}` : ''} + {/* Grade letter when the snapshot graded this player (the paid + READ layer; the letter itself is public on the slate). */} + {s.grade && {s.grade}} +
+ {/* THE LENS — the interpreted read, never the raw streak alone. */} +
{s.lens?.read || s.description}
+ {s.lens?.difficulty && ( +
+ {s.lens.difficulty === 'step up' ? '▲ TOUGHER SPOT TONIGHT' : s.lens.difficulty === 'step down' ? '▼ SOFTER SPOT TONIGHT' : ''} +
+ )}
{s.currentStreak} G
))} {hidden > 0 && ( - - {hidden} more streak{hidden === 1 ? '' : 's'} — upgrade to see all → + + {hidden} more streak{hidden === 1 ? '' : 's'} on the board → )} diff --git a/web/src/components/vyndr/PlayerStreaks.tsx b/web/src/components/vyndr/PlayerStreaks.tsx new file mode 100644 index 0000000..ce52b9b --- /dev/null +++ b/web/src/components/vyndr/PlayerStreaks.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * PlayerStreaks (Session 60, night2/C) — a player's ACTIVE streaks + form + * heat on the dossier page. FREE layer (the picture), rendered through the + * lens (the interpreted read), beside the paid intelligence blocks. + * Self-hides when the player has no active streaks. + */ + +interface StreakRow { + type: string; + currentStreak: number; + description: string; + lens?: { read?: string | null; difficulty?: string | null }; +} + +export default function PlayerStreaks({ player, sport }: { player: string; sport: string }) { + const [rows, setRows] = useState(null); + + useEffect(() => { + let active = true; + fetch(`/api/streaks/${encodeURIComponent(sport)}?player=${encodeURIComponent(player)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { if (active && data) setRows(Array.isArray(data.streaks) ? data.streaks : []); }) + .catch(() => { /* self-hide */ }); + return () => { active = false; }; + }, [player, sport]); + + if (!rows || rows.length === 0) return null; + + return ( +
+
+ 🔥 ACTIVE STREAKS +
+
+ {rows.slice(0, 5).map((s) => ( +
+ {s.currentStreak}G + {s.lens?.read || s.description} +
+ ))} +
+
+ ); +}