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
+4
View File
@@ -96,6 +96,10 @@ router.get('/model', async (req, res) => {
q = applyFilters(q, req); q = applyFilters(q, req);
// Session 60 (night2/E) — PRIOR READS: a player's own public history. // 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))); 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 const { data, error } = await q
.order('graded_at', { ascending: false }) .order('graded_at', { ascending: false })
.limit(limit); .limit(limit);
+32
View File
@@ -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) => { router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase(); const sport = String(req.params.sport || '').toLowerCase();
const date = req.query.date || scheduleService.todayET(); const date = req.query.date || scheduleService.todayET();
+74
View File
@@ -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 } };
+82
View File
@@ -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 } };
+101
View File
@@ -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);
});
});
@@ -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 });
}
}
@@ -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 });
}
}
+100 -16
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
// Session 45 — the live Slate now renders the pre-graded snapshot via the // 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. // 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 GradeIndex = ReturnType<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>; type DeltaIndex = ReturnType<typeof indexDeltas>;
type PitcherMap = ReturnType<typeof buildPitcherMap>; 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 // Session 60 (night2/C) — ONE stat selection filters every layer: props on
// the cards narrow together with the streaks + hot-list panels below. // the cards narrow together with the streaks + hot-list panels below.
const props = statFilter && statFilter !== 'all' 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) : [], lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
// Session 59 (work-order 1.6) — pass the game's participants so the join // 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). // 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). // Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined, pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })), 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`; 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 { function nickToken(name?: string | null): string {
const w = String(name || '').trim().split(/\s+/); const w = String(name || '').trim().split(/\s+/);
const last = w[w.length - 1] || ''; 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 { export interface SlateProps {
initialTab?: SlateTab; initialTab?: SlateTab;
tier?: Tier; tier?: Tier;
@@ -409,6 +447,13 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]); const [snapGrades, setSnapGrades] = useState<SnapshotGrade[]>([]);
const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]); const [snapDeltas, setSnapDeltas] = useState<SnapshotDelta[]>([]);
const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]); 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 [loading, setLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
// Session 55 — real-time freshness: when the slate last pulled fresh data, // 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. // just because one provider is down.
// Session 55 — real-time layer. `silent` background refreshes keep the slate // Session 55 — real-time layer. `silent` background refreshes keep the slate
// alive (polling) without the skeleton flash or clearing the current view. // 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) { if (!silent) {
setLoading(true); setLoading(true);
setFetchError(null); setFetchError(null);
@@ -476,15 +523,20 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
const perSport = await Promise.all( const perSport = await Promise.all(
sportsToFetch.map(async (sport) => { sportsToFetch.map(async (sport) => {
const oddsUrls = FETCH_URLS[sport] as string[]; const oddsUrls = FETCH_URLS[sport] as string[];
const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes] = await Promise.all([ // Session 64 — Yesterday/Tomorrow are schedule+results surfaces: the
Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))), // odds/grades/pitcher layers are TODAY's and never fake other dates.
SCHEDULE_SPORTS.has(sport) ? getJson<ScheduleResponse>(`/api/schedule/${sport}`) : Promise.resolve(null), const [oddsResults, schedule, lines, streaksRes, snap, pitchersRes, lineupsRes, injuriesRes] = await Promise.all([
SCHEDULE_SPORTS.has(sport) ? getJson<GameLinesResponse>(`/api/gamelines/${sport}`) : Promise.resolve(null), isToday ? Promise.all(oddsUrls.map((u) => getJson<OddsResponse>(u))) : Promise.resolve([] as (OddsResponse | null)[]),
SCHEDULE_SPORTS.has(sport) ? getJson<StreaksResponse>(`/api/streaks/${sport}`) : Promise.resolve(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). // 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. // 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); 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 oddsGames = groupByGame(oddsProps, sport);
const scheduleGames = schedule?.games || []; const scheduleGames = schedule?.games || [];
const merged = mergeSlate(sport, scheduleGames, oddsGames, lines?.games, streaksRes?.streaks); 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 allSnapGrades: SnapshotGrade[] = [];
const allSnapDeltas: SnapshotDelta[] = []; const allSnapDeltas: SnapshotDelta[] = [];
const allPitcherGames: PitcherGame[] = []; 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 anyOddsOk = false;
let anyScheduleShown = false; let anyScheduleShown = false;
for (const s of perSport) { for (const s of perSport) {
@@ -507,6 +561,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
allSnapGrades.push(...s.snapGrades); allSnapGrades.push(...s.snapGrades);
allSnapDeltas.push(...s.snapDeltas); allSnapDeltas.push(...s.snapDeltas);
allPitcherGames.push(...s.pitcherGames); 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.oddsOk) anyOddsOk = true;
if (s.hadSchedule) anyScheduleShown = true; if (s.hadSchedule) anyScheduleShown = true;
} }
@@ -522,10 +578,12 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
setSnapGrades(allSnapGrades); setSnapGrades(allSnapGrades);
setSnapDeltas(allSnapDeltas); setSnapDeltas(allSnapDeltas);
setPitcherGames(allPitcherGames); setPitcherGames(allPitcherGames);
setViability({ lineups: mergedLineups || undefined, injuries: Object.keys(mergedInjuries).length ? mergedInjuries : undefined });
setLastRefreshed(Date.now()); setLastRefreshed(Date.now());
// Odds down but schedule carried the slate → soft notice, not a wall. // 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. // Genuine total failure (no odds, no schedule, anywhere) → error.
if (!silent && !anyOddsOk && !anyScheduleShown && allGames.length === 0) { if (!silent && !anyOddsOk && !anyScheduleShown && allGames.length === 0) {
setFetchError('No games available right now. Check back soon.'); setFetchError('No games available right now. Check back soon.');
@@ -533,12 +591,12 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
setLoading(false); 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 // Session 55 — auto-refresh: poll the slate every 60s so fresh snapshot grades
// + schedule/score updates appear without a page reload. Silent (no skeleton). // + schedule/score updates appear without a page reload. Silent (no skeleton).
useEffect(() => { 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); return () => clearInterval(id);
}, [tab, fetchSlate]); }, [tab, fetchSlate]);
@@ -691,6 +749,30 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
marginBottom: 12, 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 <div
role="tablist" role="tablist"
aria-label="Sport" aria-label="Sport"
@@ -858,11 +940,13 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
</div> </div>
)} )}
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
<div style={{ display: 'grid', gap: 16 }}> <div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => ( {filteredGames.map((g, i) => (
<VyndrGameCard <VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`} 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} preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')} onOpen={() => router.push('/scan')}
/> />
+5
View File
@@ -34,6 +34,9 @@ export interface PlayerStrip {
player: string; player: string;
team: string; team: string;
archetype?: StripArchetype; 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[]; stats: StatCell[];
props: StripProp[]; props: StripProp[];
} }
@@ -303,6 +306,8 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
team={ps.team} team={ps.team}
sport={g.sport} sport={g.sport}
archetype={ps.archetype} archetype={ps.archetype}
lineup={ps.lineup}
injury={ps.injury}
stats={ps.stats} stats={ps.stats}
props={ps.props} props={ps.props}
variant="compact" variant="compact"
+52 -4
View File
@@ -28,6 +28,36 @@ export interface StripProp {
// (only set when ≥2 books post the same line and prices differ). // (only set when ≥2 books post the same line and prices differ).
book?: string | null; book?: string | null;
bestBook?: { book: string; odds: number } | 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 (
<span style={{ display: 'inline-flex', gap: 5, marginLeft: 7 }}>
{chips.map((c, i) => (
<span key={i} className="mono" title={c.title} style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.07em', padding: '1px 5px', borderRadius: 3, color: c.color, border: `1px solid color-mix(in srgb, ${c.color} 45%, transparent)` }}>
{c.label}
</span>
))}
</span>
);
} }
/** Phase 2.5 movement chip: STEAM ▲ (market chasing), VALUE ▲ (better /** Phase 2.5 movement chip: STEAM ▲ (market chasing), VALUE ▲ (better
@@ -58,6 +88,9 @@ interface StatStripProps {
team: string; team: string;
sport?: string; sport?: string;
archetype?: StripArchetype; 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[]; stats: StatCell[];
last10?: StatCell[] | string; last10?: StatCell[] | string;
props?: StripProp[]; props?: StripProp[];
@@ -85,6 +118,8 @@ export default function StatStrip({
team, team,
sport, sport,
archetype, archetype,
lineup,
injury,
stats, stats,
last10, last10,
props, props,
@@ -235,6 +270,8 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName> <PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span> <span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{/* Session 64 (A1-S5) — lineup confirmation + injury wire chips. */}
<ViabilityChips lineup={lineup} injury={injury} />
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />} {archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
{archetype?.secondary && ( {archetype?.secondary && (
<> <>
@@ -297,11 +334,22 @@ export default function StatStrip({
{p.revisedFrom && ( {p.revisedFrom && (
<span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span> <span className="mono" title="Grade revised after the line moved against the read — original preserved" style={{ fontSize: 10.5, color: 'var(--text-2)', textDecoration: 'line-through' }}>{p.revisedFrom}</span>
)} )}
{p.grade && <GradeBadge grade={p.grade} size="sm" />} {p.grade && (
<MovementChip p={p} /> p.dead ? (
// Session 64 (A1-S5) — NOT IN LINEUP: the grade is dead.
// Struck through, never deleted — the lock is history.
<span className="mono" title="Player is not in the posted lineup — this read is dead" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span style={{ textDecoration: 'line-through', color: 'var(--text-2)', fontWeight: 800, fontSize: 12 }}>{p.grade}</span>
<span style={{ fontSize: 9, fontWeight: 800, letterSpacing: '0.07em', color: 'var(--miss, #FF5252)', border: '1px solid color-mix(in srgb, var(--miss, #FF5252) 45%, transparent)', borderRadius: 3, padding: '1px 5px' }}>NOT IN LINEUP</span>
</span>
) : (
<GradeBadge grade={p.grade} size="sm" />
)
)}
{!p.dead && <MovementChip p={p} />}
<OutcomeChip p={p} /> <OutcomeChip p={p} />
{!p.outcome && <ParlayBtn p={p} />} {!p.outcome && !p.dead && <ParlayBtn p={p} />}
{!p.outcome && <BookItTeaser p={p} />} {!p.outcome && !p.dead && <BookItTeaser p={p} />}
{p.gradedAt?.ago && ( {p.gradedAt?.ago && (
<span style={{ color: 'var(--text-2)' }}> <span style={{ color: 'var(--text-2)' }}>
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''} Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
+22 -2
View File
@@ -314,9 +314,22 @@ function slateTeamsMatch(a, b) {
* @param {number} [now] * @param {number} [now]
* @param {{home?: string, away?: string} | null} [gameTeams] * @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 byPlayer = {};
const order = []; 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 || []) { for (const p of gameProps || []) {
if (!p || !p.player) continue; if (!p || !p.player) continue;
// Session 46 — group by the normalized key so name variants ("A.J. Ewing" // 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), player: displayName(p.player),
team: knownTeam, team: knownTeam,
archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined, archetype: rec && rec.archetype ? { primary: rec.archetype } : undefined,
lineup: lineupStatusFor(pk, knownTeam),
injury: injuryFor(pk),
stats: [], stats: [],
props: [], props: [],
}; };
@@ -387,7 +402,12 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
const ex = byStat.get(sk); const ex = byStat.get(sk);
if (!ex || (pr.grade && !ex.grade)) byStat.set(sk, pr); 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 };
}); });
} }