S5 (a1): prop viability — lineups, injury wire, date navigation

- lineupService: statsapi hydrate=lineups (live shape verified) →
  CONFIRMED (batting slot) / NOT_IN (team posted without the player) /
  PROJECTED (not posted). 10-min cache, pure parser, injectable.
- NOT_IN visibly KILLS the grade on the slate: struck through + NOT IN
  LINEUP chip, parlay/book actions suppressed. The locked ledger read is
  untouched — honesty is showing the read is dead, not deleting it.
- injuryService: ESPN injuries feed → OUT/GTD/PROB chips (unknown status
  → no chip, never invented). Chips on slate strips via ViabilityChips.
- Date navigation on the Slate: YESTERDAY (results surface — finals +
  THE SETTLE panel of that date's settled reads w/ outcome + CLV chips,
  via new ?date= filter on /api/ledger/model) / TODAY / TOMORROW
  (schedule until lines post). Odds/grades/pitcher layers are TODAY's
  and never fake other dates; 60s poll only refreshes today.
- Routes /api/schedule/:sport/lineups + /injuries + Next proxies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 19:38:37 -04:00
parent aaafc3e0f2
commit 02c17a65c3
11 changed files with 510 additions and 22 deletions
+100 -16
View File
@@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
// Session 45 — the live Slate now renders the pre-graded snapshot via the
// VYNDR 2.0 card. Legacy GameCard is kept ONLY for its shared types.
@@ -176,7 +176,7 @@ interface PitcherResponse { games?: PitcherGame[] }
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'): GameCardData {
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters<typeof buildPlayerStripsFromProps>[5] = 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'
@@ -194,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(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }),
playerStrips: buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }, viability),
// 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 || '' })),
@@ -229,6 +229,12 @@ function freshLabel(ts: number | null, now: number): string {
return `${Math.round(m / 60)}h ago`;
}
/** Session 64 — ET date string offset by n days (Yesterday/Tomorrow nav). */
function etDateWithOffset(offset: number): string {
const d = new Date(Date.now() + offset * 86_400_000);
return new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
}
function nickToken(name?: string | null): string {
const w = String(name || '').trim().split(/\s+/);
const last = w[w.length - 1] || '';
@@ -364,6 +370,38 @@ function groupByGame(rawProps: RawProp[], sport: SlateSport): SlateGame[] {
});
}
/** Session 64 (A1-S5) — the Yesterday results panel: that date's settled
* public reads (outcome + CLV), straight from the ledger. Self-hides empty. */
function YesterdaySettle({ date }: { date: string }) {
const [rows, setRows] = useState<Array<{ id: string; player_name: string; stat: string; line: number; side: string; grade: string; outcome?: string | null; actual_value?: number | null; clv_result?: string | null }>>([]);
useEffect(() => {
let active = true;
fetch(`/api/ledger/model?date=${date}&limit=60`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (active && d) setRows((d.entries || []).filter((e: { outcome?: string | null }) => e.outcome)); })
.catch(() => { /* self-hide */ });
return () => { active = false; };
}, [date]);
if (rows.length === 0) return null;
return (
<section style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 10, padding: 16, marginBottom: 16 }}>
<div className="mono" style={{ fontSize: 11, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--g-a)', marginBottom: 10 }}>THE SETTLE · {date}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
{rows.map((r) => (
<div key={r.id} className="mono" style={{ display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 12, flexWrap: 'wrap' }}>
<span style={{ color: 'var(--text-0)', fontWeight: 700 }}>{r.player_name}</span>
<span style={{ color: 'var(--text-1)' }}>{r.stat.replace(/_/g, ' ')} {String(r.side).toUpperCase() === 'UNDER' ? 'u' : 'o'}{r.line} · {r.grade}</span>
<span style={{ fontWeight: 800, color: r.outcome === 'hit' ? 'var(--g-a)' : r.outcome === 'miss' ? 'var(--miss)' : 'var(--text-1)' }}>
{r.outcome === 'hit' ? '✓ HIT' : r.outcome === 'miss' ? '✕ MISS' : ' PUSH'}{r.actual_value != null ? ` (${r.actual_value})` : ''}
</span>
{r.clv_result && <span style={{ fontSize: 10.5, color: r.clv_result === 'beat' ? 'var(--g-a)' : r.clv_result === 'faded' ? 'var(--miss)' : 'var(--text-2)' }}>CLV {r.clv_result.toUpperCase()}</span>}
</div>
))}
</div>
</section>
);
}
export interface SlateProps {
initialTab?: SlateTab;
tier?: Tier;
@@ -409,6 +447,13 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
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);
// Session 64 (A1-S5) — date navigation: -1 = Yesterday (results surface),
// 0 = Today, +1 = Tomorrow (schedule until lines post).
const [dateOffset, setDateOffset] = useState(0);
const dateOffsetRef = useRef(0);
useEffect(() => { dateOffsetRef.current = dateOffset; }, [dateOffset]);
const [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
// Session 55 — real-time freshness: when the slate last pulled fresh data,
@@ -435,7 +480,9 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
// just because one provider is down.
// Session 55 — real-time layer. `silent` background refreshes keep the slate
// alive (polling) without the skeleton flash or clearing the current view.
const fetchSlate = useCallback(async (active: SlateTab, silent = false) => {
const fetchSlate = useCallback(async (active: SlateTab, silent = false, offset = dateOffsetRef.current) => {
const dateParam = offset === 0 ? '' : `?date=${etDateWithOffset(offset)}`;
const isToday = offset === 0;
if (!silent) {
setLoading(true);
setFetchError(null);
@@ -476,15 +523,20 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const perSport = await Promise.all(
sportsToFetch.map(async (sport) => {
const oddsUrls = FETCH_URLS[sport] as string[];
const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes] = await Promise.all([
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
// Session 64 — Yesterday/Tomorrow are schedule+results surfaces: the
// odds/grades/pitcher layers are TODAY's and never fake other dates.
const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes, lineupsRes, injuriesRes] = await Promise.all([
isToday ? Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))) : Promise.resolve([] as (OddsResponse | null)[]),
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}${dateParam}`) : Promise.resolve(null),
isToday && SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null),
isToday && SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(null),
// Session 45 — pre-graded snapshot (locked grades + line deltas).
getJson<SnapshotResponse>(`/api/snapshot/${sport}`),
isToday ? getJson<SnapshotResponse>(`/api/snapshot/${sport}`) : Promise.resolve(null),
// Session 46 — MLB probable starting pitchers.
sport === 'mlb' ? getJson<PitcherResponse>(`/api/schedule/mlb/pitchers`) : Promise.resolve(null),
isToday && sport === 'mlb' ? getJson<PitcherResponse>(`/api/schedule/mlb/pitchers`) : Promise.resolve(null),
// Session 64 (A1-S5) — lineup confirmation (MLB) + injury wire.
isToday && sport === 'mlb' ? getJson<{ byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] }>(`/api/schedule/mlb/lineups`) : Promise.resolve(null),
isToday && SCHEDULE_SPORTS.has(sport) ? getJson<{ byPlayer: Record<string, { status: string; detail?: string | null }> }>(`/api/schedule/${sport}/injuries`) : Promise.resolve(null),
]);
const oddsOk = oddsResults.some((o) => o !== null);
@@ -492,7 +544,7 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const oddsGames = groupByGame(oddsProps, sport);
const scheduleGames = schedule?.games || [];
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks);
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [], pitcherGames: pitchersRes?.games || [] };
return { sport, merged, oddsOk, hadSchedule: scheduleGames.length > 0, snapGrades: snap?.grades || [], snapDeltas: snap?.deltas || [], pitcherGames: pitchersRes?.games || [], lineups: lineupsRes || null, injuries: injuriesRes?.byPlayer || null };
}),
);
@@ -500,6 +552,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const allSnapGrades: SnapshotGrade[] = [];
const allSnapDeltas: SnapshotDelta[] = [];
const allPitcherGames: PitcherGame[] = [];
let mergedLineups: { byPlayer: Record<string, { status: string; slot?: number }>; postedTeams: string[] } | null = null;
const mergedInjuries: Record<string, { status: string; detail?: string | null }> = {};
let anyOddsOk = false;
let anyScheduleShown = false;
for (const s of perSport) {
@@ -507,6 +561,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
allSnapGrades.push(...s.snapGrades);
allSnapDeltas.push(...s.snapDeltas);
allPitcherGames.push(...s.pitcherGames);
if (s.lineups && s.lineups.byPlayer) mergedLineups = s.lineups;
if (s.injuries) Object.assign(mergedInjuries, s.injuries);
if (s.oddsOk) anyOddsOk = true;
if (s.hadSchedule) anyScheduleShown = true;
}
@@ -522,10 +578,12 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
setSnapGrades(allSnapGrades);
setSnapDeltas(allSnapDeltas);
setPitcherGames(allPitcherGames);
setViability({ lineups: mergedLineups || undefined, injuries: Object.keys(mergedInjuries).length ? mergedInjuries : undefined });
setLastRefreshed(Date.now());
// Odds down but schedule carried the slate → soft notice, not a wall.
if (!silent && !anyOddsOk && anyScheduleShown) setOddsNotice(true);
// (Only meaningful for today — other dates are schedule surfaces.)
if (!silent && isToday && !anyOddsOk && anyScheduleShown) setOddsNotice(true);
// Genuine total failure (no odds, no schedule, anywhere) → error.
if (!silent && !anyOddsOk && !anyScheduleShown && allGames.length === 0) {
setFetchError('No games available right now. Check back soon.');
@@ -533,12 +591,12 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
setLoading(false);
}, []);
useEffect(() => { fetchSlate(tab); }, [tab, fetchSlate]);
useEffect(() => { fetchSlate(tab, false, dateOffset); }, [tab, fetchSlate, dateOffset]);
// Session 55 — auto-refresh: poll the slate every 60s so fresh snapshot grades
// + schedule/score updates appear without a page reload. Silent (no skeleton).
useEffect(() => {
const id = setInterval(() => { fetchSlate(tab, true); }, 60_000);
const id = setInterval(() => { if (dateOffsetRef.current === 0) fetchSlate(tab, true, 0); }, 60_000);
return () => clearInterval(id);
}, [tab, fetchSlate]);
@@ -691,6 +749,30 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
marginBottom: 12,
}}
/>
{/* Session 64 (A1-S5) — date navigation. Yesterday = results surface
(finals + settled reads); Tomorrow = schedule until lines post. */}
<div className="mono" style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
{([[-1, 'YESTERDAY'], [0, 'TODAY'], [1, 'TOMORROW']] as [number, string][]).map(([off, label]) => (
<button
key={off}
onClick={() => setDateOffset(off)}
className="mono"
style={{
cursor: 'pointer', padding: '5px 12px', borderRadius: 6, fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em',
background: dateOffset === off ? 'var(--g-a, #00D4A0)' : 'transparent',
color: dateOffset === off ? '#06060B' : 'var(--text-1)',
border: `1px solid ${dateOffset === off ? 'var(--g-a, #00D4A0)' : 'var(--border-hi)'}`,
}}
>
{label}
</button>
))}
{dateOffset !== 0 && (
<span className="mono" style={{ alignSelf: 'center', marginLeft: 8, fontSize: 10.5, color: 'var(--text-2)' }}>
{etDateWithOffset(dateOffset)} · {dateOffset < 0 ? 'results + settled reads' : 'schedule — lines post on the day'}
</span>
)}
</div>
<div
role="tablist"
aria-label="Sport"
@@ -858,11 +940,13 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
</div>
)}
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
<div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => (
<VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat)}
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability)}
preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')}
/>