From 02c17a65c3e8863343a394d6172cb21ade8d810f Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 11 Jul 2026 19:38:37 -0400 Subject: [PATCH] =?UTF-8?q?S5=20(a1):=20prop=20viability=20=E2=80=94=20lin?= =?UTF-8?q?eups,=20injury=20wire,=20date=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/routes/ledger.js | 4 + src/routes/schedule.js | 32 +++++ src/services/injuryService.js | 74 +++++++++++ src/services/lineupService.js | 82 +++++++++++++ tests/unit/propViability.test.js | 101 +++++++++++++++ .../api/schedule/[sport]/injuries/route.ts | 19 +++ .../app/api/schedule/[sport]/lineups/route.ts | 19 +++ web/src/components/Slate.tsx | 116 +++++++++++++++--- web/src/components/vyndr/GameCard.tsx | 5 + web/src/components/vyndr/StatStrip.tsx | 56 ++++++++- web/src/lib/slateAdapter.js | 24 +++- 11 files changed, 510 insertions(+), 22 deletions(-) create mode 100644 src/services/injuryService.js create mode 100644 src/services/lineupService.js create mode 100644 tests/unit/propViability.test.js create mode 100644 web/src/app/api/schedule/[sport]/injuries/route.ts create mode 100644 web/src/app/api/schedule/[sport]/lineups/route.ts diff --git a/src/routes/ledger.js b/src/routes/ledger.js index 9d42e17..71528b7 100644 --- a/src/routes/ledger.js +++ b/src/routes/ledger.js @@ -96,6 +96,10 @@ router.get('/model', async (req, res) => { q = applyFilters(q, req); // Session 60 (night2/E) — PRIOR READS: a player's own public history. if (req.query.player) q = q.eq('player_key', nameKey(String(req.query.player).slice(0, 60))); + // Session 64 (A1-S5) — the Yesterday results surface: rows for one game date. + if (req.query.date && /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.date))) { + q = q.eq('game_date', String(req.query.date)); + } const { data, error } = await q .order('graded_at', { ascending: false }) .limit(limit); diff --git a/src/routes/schedule.js b/src/routes/schedule.js index 52ea631..b5aa6ac 100644 --- a/src/routes/schedule.js +++ b/src/routes/schedule.js @@ -49,6 +49,38 @@ router.get('/:sport/pitchers', async (req, res) => { } }); +// Session 64 (A1-S5) — MLB lineup confirmation. { byPlayer, postedTeams }; +// consumers resolve CONFIRMED / NOT_IN / PROJECTED via the posted-team rule. +router.get('/:sport/lineups', async (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + if (sport !== 'mlb') return res.set(MISSION_HEADER).json({ sport, byPlayer: {}, postedTeams: [] }); + const date = req.query.date || scheduleService.todayET(); + try { + const { fetchLineups } = require('../services/lineupService'); + const parsed = await fetchLineups(date); + res.set('Cache-Control', 'public, max-age=300'); + return res.set(MISSION_HEADER).json({ sport, date, ...parsed }); + } catch (err) { + console.error('[schedule/lineups]', err.message); + return res.set(MISSION_HEADER).json({ sport, date, byPlayer: {}, postedTeams: [] }); + } +}); + +// Session 64 (A1-S5) — the injury wire (ESPN feed): { byPlayer: { key: +// { status: OUT|GTD|PROB, detail, team } } }. Accurate chips, no cascades yet. +router.get('/:sport/injuries', async (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + try { + const { fetchInjuries } = require('../services/injuryService'); + const byPlayer = await fetchInjuries(sport); + res.set('Cache-Control', 'public, max-age=600'); + return res.set(MISSION_HEADER).json({ sport, byPlayer }); + } catch (err) { + console.error('[schedule/injuries]', err.message); + return res.set(MISSION_HEADER).json({ sport, byPlayer: {} }); + } +}); + router.get('/:sport', async (req, res) => { const sport = String(req.params.sport || '').toLowerCase(); const date = req.query.date || scheduleService.todayET(); diff --git a/src/services/injuryService.js b/src/services/injuryService.js new file mode 100644 index 0000000..3528e89 --- /dev/null +++ b/src/services/injuryService.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * injuryService — the real injury wire (Session 64 / A1-S5). + * + * FREE ESPN injuries feed per sport → per-player status chips: + * OUT / GTD (day-to-day, questionable) / PROB. Day 1 is ACCURATE CHIPS on + * player pages + prop rows; cascade auto-regrades are a future board. + * Cache 15 min. Pure parser + injectable fetch → unit-tested offline. + */ + +const { nameKey } = require('../utils/playerName'); + +const TTL = 900; +const HTTP_TIMEOUT_MS = 10_000; +const FEEDS = { + mlb: 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/injuries', + wnba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/injuries', + nba: 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/injuries', +}; + +/** ESPN status text → the three honest chips (unknown → null, never guessed). */ +function chipFor(statusText) { + const s = String(statusText || '').toLowerCase(); + if (!s) return null; + if (s.includes('out') || s.includes('injured list') || s.includes('il-') || s === 'suspension') return 'OUT'; + if (s.includes('day-to-day') || s.includes('questionable') || s.includes('doubtful') || s === 'gtd') return 'GTD'; + if (s.includes('probable')) return 'PROB'; + return null; +} + +/** Pure: ESPN injuries JSON → { nameKey: { status, detail, team } } */ +function parseInjuries(json) { + const byPlayer = {}; + for (const teamBlock of (json && json.injuries) || []) { + const team = teamBlock.displayName || null; + for (const inj of teamBlock.injuries || []) { + const athlete = inj.athlete || {}; + const name = athlete.displayName || athlete.fullName; + if (!name) continue; + const chip = chipFor(inj.status); + if (!chip) continue; + byPlayer[nameKey(name)] = { + status: chip, + detail: (inj.details && inj.details.type) || inj.shortComment || null, + team, + }; + } + } + return byPlayer; +} + +async function fetchInjuries(sport, opts = {}) { + const sp = String(sport || '').toLowerCase(); + const url = FEEDS[sp]; + if (!url) return {}; + const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet; + const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet; + const key = `injuries:${sp}`; + const cached = await cacheGet(key); + if (cached) return cached; + const axios = opts.axios || require('axios'); + try { + const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS }); + const parsed = parseInjuries(res.data); + await cacheSet(key, parsed, TTL); + return parsed; + } catch (e) { + console.warn(`[injuries] ${sp} fetch failed:`, e.message); + return {}; + } +} + +module.exports = { fetchInjuries, parseInjuries, chipFor, __internals: { TTL, FEEDS } }; diff --git a/src/services/lineupService.js b/src/services/lineupService.js new file mode 100644 index 0000000..bec17f2 --- /dev/null +++ b/src/services/lineupService.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * lineupService — MLB lineup confirmation (Session 64 / A1-S5). + * + * FREE statsapi `schedule?hydrate=lineups`: once a team posts its lineup the + * feed carries homePlayers/awayPlayers in batting order. From that, per + * player (nameKey): + * CONFIRMED (with batting slot) — the player is in a posted lineup. + * NOT_IN — his team's lineup IS posted and he isn't in it. This visibly + * kills the grade on the slate (struck through + chip). The + * locked ledger read is untouched — honesty means SHOWING the + * read is dead, not deleting it. + * (absent) — his team hasn't posted yet → the UI renders PROJECTED. + * + * Cache 10 min (lineups post in waves pre-game). Pure parser + injectable + * fetch → unit-tested on the real feed shape with zero network. + */ + +const { nameKey } = require('../utils/playerName'); + +const TTL = 600; // 10 min +const BASE = 'https://statsapi.mlb.com/api/v1'; +const HTTP_TIMEOUT_MS = 10_000; + +/** + * Pure: statsapi schedule JSON → { + * byPlayer: { nameKey: { status:'confirmed', slot, team } }, + * postedTeams: [team names whose lineup is up], + * } + * Players NOT in byPlayer whose team IS in postedTeams are NOT_IN — + * resolved by statusFor(). + */ +function parseLineups(scheduleJson) { + const byPlayer = {}; + const postedTeams = []; + const games = ((scheduleJson || {}).dates || [])[0]?.games || []; + for (const g of games) { + const lu = g.lineups || {}; + for (const side of ['home', 'away']) { + const players = lu[`${side}Players`]; + if (!Array.isArray(players) || players.length === 0) continue; + const team = g.teams?.[side]?.team?.name || null; + if (team) postedTeams.push(team); + players.forEach((p, i) => { + if (!p || !p.fullName) return; + byPlayer[nameKey(p.fullName)] = { status: 'confirmed', slot: i + 1, team }; + }); + } + } + return { byPlayer, postedTeams }; +} + +/** Resolve one player's viability given parsed lineups + his team. */ +function statusFor(player, team, parsed) { + if (!parsed) return { status: 'projected' }; + const hit = parsed.byPlayer[nameKey(player)]; + if (hit) return hit; + const token = (n) => String(n || '').toLowerCase().split(/\s+/).pop(); + const posted = team && parsed.postedTeams.some((t) => t === team || token(t) === token(team)); + return posted ? { status: 'not_in', team } : { status: 'projected' }; +} + +async function fetchLineups(date, opts = {}) { + const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet; + const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet; + const key = `lineups:mlb:${date}`; + const cached = await cacheGet(key); + if (cached) return cached; + const axios = opts.axios || require('axios'); + try { + const res = await axios.get(`${BASE}/schedule?sportId=1&date=${date}&hydrate=lineups`, { timeout: HTTP_TIMEOUT_MS }); + const parsed = parseLineups(res.data); + await cacheSet(key, parsed, TTL); + return parsed; + } catch (e) { + console.warn('[lineups] fetch failed:', e.message); + return { byPlayer: {}, postedTeams: [] }; + } +} + +module.exports = { fetchLineups, parseLineups, statusFor, __internals: { TTL, BASE } }; diff --git a/tests/unit/propViability.test.js b/tests/unit/propViability.test.js new file mode 100644 index 0000000..09a57b0 --- /dev/null +++ b/tests/unit/propViability.test.js @@ -0,0 +1,101 @@ +// Session 64 (A1-S5) — prop viability: lineup confirmation + injury wire + +// the NOT-IN grade kill. Real feeds only; absent → no chips, never guessed. + +const { parseLineups, statusFor } = require('../../src/services/lineupService'); +const { parseInjuries, chipFor } = require('../../src/services/injuryService'); +const adapter = require('../../web/src/lib/slateAdapter'); + +// Real statsapi hydrate=lineups shape (verified live 2026-07-11). +const SCHEDULE_JSON = { + dates: [{ + games: [ + { + gamePk: 823357, + teams: { home: { team: { name: 'Milwaukee Brewers' } }, away: { team: { name: 'Pittsburgh Pirates' } } }, + lineups: { + homePlayers: [ + { id: 663968, fullName: 'Jake Mangum' }, + { id: 664040, fullName: 'Brandon Lowe' }, + ], + awayPlayers: [{ id: 1, fullName: 'Bryan Reynolds' }], + }, + }, + { gamePk: 823356, teams: { home: { team: { name: 'Detroit Tigers' } }, away: { team: { name: 'Tampa Bay Rays' } } }, lineups: {} }, + ], + }], +}; + +describe('lineupService — CONFIRMED / NOT_IN / PROJECTED', () => { + const parsed = parseLineups(SCHEDULE_JSON); + + test('posted lineup → confirmed with the batting slot', () => { + expect(parsed.byPlayer['brandon lowe']).toEqual({ status: 'confirmed', slot: 2, team: 'Milwaukee Brewers' }); + expect(parsed.postedTeams).toContain('Milwaukee Brewers'); + }); + + test('team posted, player absent → NOT_IN', () => { + expect(statusFor('Christian Yelich', 'Milwaukee Brewers', parsed).status).toBe('not_in'); + }); + + test('team not posted → PROJECTED (never guessed dead)', () => { + expect(statusFor('Riley Greene', 'Detroit Tigers', parsed).status).toBe('projected'); + }); + + test('accented/variant names resolve through nameKey', () => { + expect(statusFor('Brandon Lowé', 'Milwaukee Brewers', parsed).status).toBe('confirmed'); + }); +}); + +describe('injuryService — OUT / GTD / PROB chips', () => { + test('parses the ESPN feed shape', () => { + const byPlayer = parseInjuries({ + injuries: [{ + displayName: 'Arizona Diamondbacks', + injuries: [ + { athlete: { displayName: 'Ketel Marte' }, status: 'Out', details: { type: 'Hamstring' } }, + { athlete: { displayName: 'Corbin Carroll' }, status: 'Day-To-Day', shortComment: 'wrist' }, + ], + }], + }); + expect(byPlayer['ketel marte']).toMatchObject({ status: 'OUT', detail: 'Hamstring' }); + expect(byPlayer['corbin carroll'].status).toBe('GTD'); + }); + test('unknown status text → no chip (never invented)', () => { + expect(chipFor('Active')).toBeNull(); + expect(chipFor('')).toBeNull(); + }); +}); + +describe('slateAdapter — NOT_IN kills the graded props on the strip', () => { + const GAME = { home: 'Milwaukee Brewers', away: 'Pittsburgh Pirates' }; + const props = [{ player: 'Christian Yelich', stat_type: 'hits', line: 0.5 }]; + const gradeIndex = adapter.indexGrades([ + { player: 'Christian Yelich', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'A', + team: 'Milwaukee Brewers', gradedAt: { line: 0.5, timestamp: 'x' } }, + ]); + // Fixture keys computed via nameKey — the same folding BOTH real sides use + // (hand-written keys miss nickname resolution, e.g. jake→jacob). + const { nameKey } = require('../../web/src/lib/playerName'); + const viability = { + lineups: { byPlayer: { [nameKey('Jake Mangum')]: { status: 'confirmed', slot: 1 } }, postedTeams: ['Milwaukee Brewers'] }, + injuries: {}, + }; + + test('graded prop for a NOT_IN player renders dead (grade preserved)', () => { + const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME, viability); + expect(strips[0].lineup.status).toBe('not_in'); + expect(strips[0].props[0].dead).toBe(true); + expect(strips[0].props[0].grade).toBe('A'); // struck through in UI, never deleted + }); + + test('confirmed player carries the slot; no viability feed → no chips', () => { + const confirmed = adapter.buildPlayerStripsFromProps( + [{ player: 'Jake Mangum', stat_type: 'hits', line: 0.5 }], + {}, {}, Date.now(), GAME, viability, + ); + expect(confirmed[0].lineup).toEqual({ status: 'confirmed', slot: 1 }); + const none = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}, Date.now(), GAME, null); + expect(none[0].lineup == null).toBe(true); + expect(none[0].props[0].dead == null).toBe(true); + }); +}); diff --git a/web/src/app/api/schedule/[sport]/injuries/route.ts b/web/src/app/api/schedule/[sport]/injuries/route.ts new file mode 100644 index 0000000..d0b3739 --- /dev/null +++ b/web/src/app/api/schedule/[sport]/injuries/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** Injury-wire proxy (Session 64 / A1-S5). */ +export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) { + const { sport } = await params; + try { + const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(String(sport).toLowerCase())}/injuries`, { + headers: { Accept: 'application/json' }, + }); + const data = await upstream.json().catch(() => ({ byPlayer: {} })); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ byPlayer: {} }, { status: 200 }); + } +} diff --git a/web/src/app/api/schedule/[sport]/lineups/route.ts b/web/src/app/api/schedule/[sport]/lineups/route.ts new file mode 100644 index 0000000..10e4d74 --- /dev/null +++ b/web/src/app/api/schedule/[sport]/lineups/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** Lineup-confirmation proxy (Session 64 / A1-S5). */ +export async function GET(req: NextRequest, { params }: { params: Promise<{ sport: string }> }) { + const { sport } = await params; + try { + const upstream = await fetch(`${BACKEND_URL}/api/schedule/${encodeURIComponent(String(sport).toLowerCase())}/lineups${req.nextUrl.search}`, { + headers: { Accept: 'application/json' }, + }); + const data = await upstream.json().catch(() => ({ byPlayer: {}, postedTeams: [] })); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ byPlayer: {}, postedTeams: [] }, { status: 200 }); + } +} diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index dafd884..4a06068 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -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; type DeltaIndex = ReturnType; type PitcherMap = ReturnType; -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[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>([]); + 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 ( +
+
THE SETTLE · {date}
+
+ {rows.map((r) => ( +
+ {r.player_name} + {r.stat.replace(/_/g, ' ')} {String(r.side).toUpperCase() === 'UNDER' ? 'u' : 'o'}{r.line} · {r.grade} + + {r.outcome === 'hit' ? '✓ HIT' : r.outcome === 'miss' ? '✕ MISS' : '– PUSH'}{r.actual_value != null ? ` (${r.actual_value})` : ''} + + {r.clv_result && CLV {r.clv_result.toUpperCase()}} +
+ ))} +
+
+ ); +} + export interface SlateProps { initialTab?: SlateTab; tier?: Tier; @@ -409,6 +447,13 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook const [snapGrades, setSnapGrades] = useState([]); const [snapDeltas, setSnapDeltas] = useState([]); const [pitcherGames, setPitcherGames] = useState([]); + // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). + const [viability, setViability] = useState<{ lineups?: { byPlayer: Record; postedTeams: string[] }; injuries?: Record } | 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(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(u))), - SCHEDULE_SPORTS.has(sport) ? getJson(`/api/schedule/${sport}`) : Promise.resolve(null), - SCHEDULE_SPORTS.has(sport) ? getJson(`/api/gamelines/${sport}`) : Promise.resolve(null), - SCHEDULE_SPORTS.has(sport) ? getJson(`/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(u))) : Promise.resolve([] as (OddsResponse | null)[]), + SCHEDULE_SPORTS.has(sport) ? getJson(`/api/schedule/${sport}${dateParam}`) : Promise.resolve(null), + isToday && SCHEDULE_SPORTS.has(sport) ? getJson(`/api/gamelines/${sport}`) : Promise.resolve(null), + isToday && SCHEDULE_SPORTS.has(sport) ? getJson(`/api/streaks/${sport}`) : Promise.resolve(null), // Session 45 — pre-graded snapshot (locked grades + line deltas). - getJson(`/api/snapshot/${sport}`), + isToday ? getJson(`/api/snapshot/${sport}`) : Promise.resolve(null), // Session 46 — MLB probable starting pitchers. - sport === 'mlb' ? getJson(`/api/schedule/mlb/pitchers`) : Promise.resolve(null), + isToday && sport === 'mlb' ? getJson(`/api/schedule/mlb/pitchers`) : Promise.resolve(null), + // Session 64 (A1-S5) — lineup confirmation (MLB) + injury wire. + isToday && sport === 'mlb' ? getJson<{ byPlayer: Record; postedTeams: string[] }>(`/api/schedule/mlb/lineups`) : Promise.resolve(null), + isToday && SCHEDULE_SPORTS.has(sport) ? getJson<{ byPlayer: Record }>(`/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; postedTeams: string[] } | null = null; + const mergedInjuries: Record = {}; 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. */} +
+ {([[-1, 'YESTERDAY'], [0, 'TODAY'], [1, 'TOMORROW']] as [number, string][]).map(([off, label]) => ( + + ))} + {dateOffset !== 0 && ( + + {etDateWithOffset(dateOffset)} · {dateOffset < 0 ? 'results + settled reads' : 'schedule — lines post on the day'} + + )} +
)} + {dateOffset === -1 && } +
{filteredGames.map((g, i) => ( router.push('/scan')} /> diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index 9881dbe..ef958c2 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -34,6 +34,9 @@ export interface PlayerStrip { player: string; team: string; archetype?: StripArchetype; + // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). + lineup?: { status: string; slot?: number } | null; + injury?: { status: string; detail?: string | null } | null; stats: StatCell[]; props: StripProp[]; } @@ -303,6 +306,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks team={ps.team} sport={g.sport} archetype={ps.archetype} + lineup={ps.lineup} + injury={ps.injury} stats={ps.stats} props={ps.props} variant="compact" diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index c0d3336..9be23eb 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -28,6 +28,36 @@ export interface StripProp { // (only set when ≥2 books post the same line and prices differ). book?: string | null; bestBook?: { book: string; odds: number } | null; + // Session 64 (A1-S5) — NOT-IN-LINEUP kills the grade display (struck + // through, actions suppressed). The locked ledger read is untouched. + dead?: boolean; +} + +/** Session 64 (A1-S5) — lineup + injury viability chips (real feeds only). */ +export function ViabilityChips({ lineup, injury }: { + lineup?: { status: string; slot?: number } | null; + injury?: { status: string; detail?: string | null } | null; +}) { + const chips: Array<{ label: string; color: string; title?: string }> = []; + if (lineup) { + if (lineup.status === 'confirmed') chips.push({ label: `CONFIRMED${lineup.slot ? ` · #${lineup.slot}` : ''}`, color: 'var(--g-a, #00D4A0)', title: 'In the posted lineup' }); + else if (lineup.status === 'not_in') chips.push({ label: 'NOT IN LINEUP', color: 'var(--miss, #FF5252)', title: 'Lineup posted without this player — the read is dead' }); + else if (lineup.status === 'projected') chips.push({ label: 'PROJ', color: 'var(--text-2, #4A4A5E)', title: 'Lineup not posted yet' }); + } + if (injury) { + const color = injury.status === 'OUT' ? 'var(--miss, #FF5252)' : injury.status === 'GTD' ? 'var(--amber, #FFB347)' : 'var(--text-1, #7A7A8E)'; + chips.push({ label: injury.status, color, title: injury.detail || undefined }); + } + if (chips.length === 0) return null; + return ( + + {chips.map((c, i) => ( + + {c.label} + + ))} + + ); } /** Phase 2.5 movement chip: STEAM ▲ (market chasing), VALUE ▲ (better @@ -58,6 +88,9 @@ interface StatStripProps { team: string; sport?: string; archetype?: StripArchetype; + // Session 64 (A1-S5) — viability (lineup confirmation + injury wire). + lineup?: { status: string; slot?: number } | null; + injury?: { status: string; detail?: string | null } | null; stats: StatCell[]; last10?: StatCell[] | string; props?: StripProp[]; @@ -85,6 +118,8 @@ export default function StatStrip({ team, sport, archetype, + lineup, + injury, stats, last10, props, @@ -235,6 +270,8 @@ export default function StatStrip({
{player} {team} + {/* Session 64 (A1-S5) — lineup confirmation + injury wire chips. */} + {archetype && } {archetype?.secondary && ( <> @@ -297,11 +334,22 @@ export default function StatStrip({ {p.revisedFrom && ( {p.revisedFrom} )} - {p.grade && } - + {p.grade && ( + p.dead ? ( + // Session 64 (A1-S5) — NOT IN LINEUP: the grade is dead. + // Struck through, never deleted — the lock is history. + + {p.grade} + NOT IN LINEUP + + ) : ( + + ) + )} + {!p.dead && } - {!p.outcome && } - {!p.outcome && } + {!p.outcome && !p.dead && } + {!p.outcome && !p.dead && } {p.gradedAt?.ago && ( Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''} diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index 0bc7e2d..d37935a 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -314,9 +314,22 @@ function slateTeamsMatch(a, b) { * @param {number} [now] * @param {{home?: string, away?: string} | null} [gameTeams] */ -function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now(), gameTeams = null) { +function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Date.now(), gameTeams = null, viability = null) { const byPlayer = {}; const order = []; + // Session 64 (A1-S5) — PROP VIABILITY resolution per player: + // lineups.byPlayer hit → CONFIRMED (slot n) + // team posted, player absent → NOT_IN (grade renders dead) + // team not posted → PROJECTED. Absent feeds → no chips at all. + const lineupStatusFor = (pk, team) => { + const lu = viability && viability.lineups; + if (!lu || !lu.byPlayer || Object.keys(lu.byPlayer).length === 0) return null; + if (lu.byPlayer[pk]) return lu.byPlayer[pk]; + const posted = team && Array.isArray(lu.postedTeams) + && lu.postedTeams.some((t) => slateTeamsMatch(t, team)); + return posted ? { status: 'not_in' } : { status: 'projected' }; + }; + const injuryFor = (pk) => (viability && viability.injuries && viability.injuries[pk]) || null; for (const p of gameProps || []) { if (!p || !p.player) continue; // Session 46 — group by the normalized key so name variants ("A.J. Ewing" @@ -334,6 +347,8 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat player: displayName(p.player), team: knownTeam, archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined, + lineup: lineupStatusFor(pk, knownTeam), + injury: injuryFor(pk), stats: [], props: [], }; @@ -387,7 +402,12 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat const ex = byStat.get(sk); if (!ex || (pr.grade && !ex.grade)) byStat.set(sk, pr); } - return { ...e, props: [...byStat.values()] }; + // Session 64 (A1-S5) — NOT-IN visibly kills every graded prop on the + // strip (struck through + chip in the UI). The locked ledger read is + // untouched — honesty is SHOWING the read is dead, not deleting it. + const dead = e.lineup && e.lineup.status === 'not_in'; + const props = [...byStat.values()].map((pr) => (dead && pr.grade ? { ...pr, dead: true } : pr)); + return { ...e, props }; }); }