S11 (a1): live tracking — the read locked, the game watched

MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-12 00:45:29 -04:00
parent 14dc9cf270
commit b5d3fd14bb
18 changed files with 1666 additions and 18 deletions
+80 -6
View File
@@ -8,6 +8,9 @@ import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
// Grades never change in-game; these marks are tracking, labeled as such.
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
import { emptyStateCopy } from '@/lib/emptyState';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
@@ -171,17 +174,31 @@ interface PitcherSide { team?: string | null; pitcher?: string | null; era?: num
interface PitcherGame { home?: PitcherSide; away?: PitcherSide }
interface PitcherResponse { games?: PitcherGame[] }
// A1 S11 — /api/live/:sport response (liveTrackingService envelope).
interface LivePlayerEntry { name?: string; team?: string | null; values?: Record<string, number> }
interface LiveGame { id: string; home?: string | null; away?: string | null; progress?: { label?: string; fraction?: number } | null; players?: Record<string, LivePlayerEntry> }
interface LiveResponse { sport?: string; hasLive?: boolean; games?: LiveGame[] }
type LiveIndex = ReturnType<typeof buildLiveIndex>;
// Sports with a free live box feed wired (specs/LIVE-TRACKING.md).
const LIVE_TRACK_SPORTS = new Set<SlateSport>(['mlb', 'wnba']);
// Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
type GradeIndex = ReturnType<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>;
type PitcherMap = ReturnType<typeof buildPitcherMap>;
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters<typeof buildPlayerStripsFromProps>[5] = null): GameCardData {
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters<typeof buildPlayerStripsFromProps>[5] = null, liveIndex: LiveIndex | null = null): 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;
// A1 S11 — overlay live box-line tracking onto the built strips, but ONLY
// for games the schedule marks in-progress (a shared player name in another
// game must not leak marks onto a pre-game card).
const strips = buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }, viability);
const liveStrips = g.status === 'in' && liveIndex ? attachLiveProgress(strips, liveIndex) : strips;
return {
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
sport: g.sport,
@@ -192,9 +209,9 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
time: formatGameTime(g.gameTime),
venue: g.venue,
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(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }, viability),
// Session 59 (work-order 1.6) — the join guard drops bad feed rows inside
// buildPlayerStripsFromProps (game participants passed above).
playerStrips: liveStrips,
// 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 || '' })),
@@ -449,6 +466,9 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
const [viability, setViability] = useState<{ lineups?: { byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] }; injuries?: Record<string, { status: string; detail?: string | null }> } | null>(null);
// A1 S11 — live tracking responses per sport (polled only while live games
// are on screen; the backend cache makes this ~1 upstream call per game/90s).
const [liveBySport, setLiveBySport] = useState<Record<string, LiveResponse>>({});
// Session 64 (A1-S5) — date navigation: -1 = Yesterday (results surface),
// 0 = Today, +1 = Tomorrow (schedule until lines post).
const [dateOffset, setDateOffset] = useState(0);
@@ -606,6 +626,47 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
return () => clearInterval(id);
}, []);
// A1 S11 — LIVE SLATE MODE poll. Fetch /api/live/{sport} every 60s ONLY
// while live games for a track-able sport are on screen (today's slate).
// Nothing live → no polling at all; the shared backend cache (90s) means
// the upstream cost is ~1 boxscore call per live game per window TOTAL.
const liveSportsKey = useMemo(() => {
if (dateOffset !== 0) return '';
const sports = new Set<string>();
for (const g of games) {
if (g.status === 'in' && LIVE_TRACK_SPORTS.has(g.sport)) sports.add(g.sport);
}
return [...sports].sort().join(',');
}, [games, dateOffset]);
useEffect(() => {
if (!liveSportsKey) { setLiveBySport({}); return; }
const sports = liveSportsKey.split(',');
let cancelled = false;
const poll = async () => {
const entries = await Promise.all(sports.map(async (sport) => {
try {
const r = await fetch(`/api/live/${sport}`, { cache: 'no-store' });
if (!r.ok) return [sport, null] as const;
return [sport, (await r.json()) as LiveResponse] as const;
} catch { return [sport, null] as const; }
}));
if (cancelled) return;
setLiveBySport((prev) => {
const next: Record<string, LiveResponse> = {};
for (const [sport, resp] of entries) {
// A transient fetch failure keeps the previous live view (never blank
// a good in-progress mark on a blip).
if (resp) next[sport] = resp;
else if (prev[sport]) next[sport] = prev[sport];
}
return next;
});
};
poll();
const id = setInterval(poll, 60_000);
return () => { cancelled = true; clearInterval(id); };
}, [liveSportsKey]);
// Session 24 — switching sport resets the stat filter. The categories
// differ per sport (Points vs Hits), so a stale "points" filter would
// silently blank the MLB panels. Always land back on 'all'.
@@ -647,6 +708,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
const pitcherMap = useMemo(() => buildPitcherMap(pitcherGames), [pitcherGames]);
// A1 S11 — one merged live index across the polled sports.
const liveIndex = useMemo(() => buildLiveIndex(Object.values(liveBySport)), [liveBySport]);
const filteredGames = useMemo(() => {
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
@@ -668,6 +731,17 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
.filter((g): g is SlateGame => g !== null);
}, [games, searchQuery]);
// A1 S11 — live games with TRACKED props float to the top, ordered by
// proximity-to-hit (pure sortLiveFirst; everything else keeps tip-off order).
const orderedGames = useMemo<SlateGame[]>(() => {
if (!liveIndex || liveIndex.count === 0) return filteredGames;
return sortLiveFirst(filteredGames, (g: SlateGame) => (
g.status === 'in'
? gameLiveProximity(g.props, gradeIndex, liveIndex)
: { tracked: false, proximity: 0 }
)) as SlateGame[];
}, [filteredGames, gradeIndex, liveIndex]);
// Session 25 — per-sport game counts for the tab labels, derived from
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
// free ESPN schedule even when odds are empty. Counts only appear for
@@ -943,10 +1017,10 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
<div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => (
{orderedGames.map((g, i) => (
<VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability)}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex)}
preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')}
/>