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