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:
+100
-16
@@ -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')}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user