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:
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 } };
|
||||||
@@ -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 } };
|
||||||
@@ -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
@@ -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')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}` : ''}
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user