S11 (a1): live tracking — the read locked, the game watched
MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/** Live-tracking proxy (A1 Session 11) — forwards GET /api/live/:sport.
|
||||
* The browser hits the Next origin, never Express directly (S25 rule). */
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ sport: string }> }) {
|
||||
const { sport } = await ctx.params;
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/live/${encodeURIComponent(sport)}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({ sport, hasLive: false, games: [] }));
|
||||
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ sport, hasLive: false, games: [] }, { status: 200 });
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard';
|
||||
import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
|
||||
import { PropRowProp, Tier } from '@/components/PropRow';
|
||||
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter';
|
||||
// A1 S11 — LIVE SLATE MODE: pure live-tracking join + proximity sort.
|
||||
// Grades never change in-game; these marks are tracking, labeled as such.
|
||||
import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress';
|
||||
import { emptyStateCopy } from '@/lib/emptyState';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
// Session 23 — all-day intelligence layer. The stat filter is the
|
||||
@@ -171,17 +174,31 @@ interface PitcherSide { team?: string | null; pitcher?: string | null; era?: num
|
||||
interface PitcherGame { home?: PitcherSide; away?: PitcherSide }
|
||||
interface PitcherResponse { games?: PitcherGame[] }
|
||||
|
||||
// A1 S11 — /api/live/:sport response (liveTrackingService envelope).
|
||||
interface LivePlayerEntry { name?: string; team?: string | null; values?: Record<string, number> }
|
||||
interface LiveGame { id: string; home?: string | null; away?: string | null; progress?: { label?: string; fraction?: number } | null; players?: Record<string, LivePlayerEntry> }
|
||||
interface LiveResponse { sport?: string; hasLive?: boolean; games?: LiveGame[] }
|
||||
type LiveIndex = ReturnType<typeof buildLiveIndex>;
|
||||
|
||||
// Sports with a free live box feed wired (specs/LIVE-TRACKING.md).
|
||||
const LIVE_TRACK_SPORTS = new Set<SlateSport>(['mlb', 'wnba']);
|
||||
|
||||
// Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
|
||||
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
|
||||
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', viability: Parameters<typeof buildPlayerStripsFromProps>[5] = null): GameCardData {
|
||||
function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters<typeof buildPlayerStripsFromProps>[5] = null, liveIndex: LiveIndex | null = 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'
|
||||
? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter)
|
||||
: g.props;
|
||||
// A1 S11 — overlay live box-line tracking onto the built strips, but ONLY
|
||||
// for games the schedule marks in-progress (a shared player name in another
|
||||
// game must not leak marks onto a pre-game card).
|
||||
const strips = buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }, viability);
|
||||
const liveStrips = g.status === 'in' && liveIndex ? attachLiveProgress(strips, liveIndex) : strips;
|
||||
return {
|
||||
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
|
||||
sport: g.sport,
|
||||
@@ -192,9 +209,9 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
|
||||
time: formatGameTime(g.gameTime),
|
||||
venue: g.venue,
|
||||
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 }, viability),
|
||||
// Session 59 (work-order 1.6) — the join guard drops bad feed rows inside
|
||||
// buildPlayerStripsFromProps (game participants passed above).
|
||||
playerStrips: liveStrips,
|
||||
// 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 || '' })),
|
||||
@@ -449,6 +466,9 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
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);
|
||||
// A1 S11 — live tracking responses per sport (polled only while live games
|
||||
// are on screen; the backend cache makes this ~1 upstream call per game/90s).
|
||||
const [liveBySport, setLiveBySport] = useState<Record<string, LiveResponse>>({});
|
||||
// Session 64 (A1-S5) — date navigation: -1 = Yesterday (results surface),
|
||||
// 0 = Today, +1 = Tomorrow (schedule until lines post).
|
||||
const [dateOffset, setDateOffset] = useState(0);
|
||||
@@ -606,6 +626,47 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// A1 S11 — LIVE SLATE MODE poll. Fetch /api/live/{sport} every 60s ONLY
|
||||
// while live games for a track-able sport are on screen (today's slate).
|
||||
// Nothing live → no polling at all; the shared backend cache (90s) means
|
||||
// the upstream cost is ~1 boxscore call per live game per window TOTAL.
|
||||
const liveSportsKey = useMemo(() => {
|
||||
if (dateOffset !== 0) return '';
|
||||
const sports = new Set<string>();
|
||||
for (const g of games) {
|
||||
if (g.status === 'in' && LIVE_TRACK_SPORTS.has(g.sport)) sports.add(g.sport);
|
||||
}
|
||||
return [...sports].sort().join(',');
|
||||
}, [games, dateOffset]);
|
||||
useEffect(() => {
|
||||
if (!liveSportsKey) { setLiveBySport({}); return; }
|
||||
const sports = liveSportsKey.split(',');
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
const entries = await Promise.all(sports.map(async (sport) => {
|
||||
try {
|
||||
const r = await fetch(`/api/live/${sport}`, { cache: 'no-store' });
|
||||
if (!r.ok) return [sport, null] as const;
|
||||
return [sport, (await r.json()) as LiveResponse] as const;
|
||||
} catch { return [sport, null] as const; }
|
||||
}));
|
||||
if (cancelled) return;
|
||||
setLiveBySport((prev) => {
|
||||
const next: Record<string, LiveResponse> = {};
|
||||
for (const [sport, resp] of entries) {
|
||||
// A transient fetch failure keeps the previous live view (never blank
|
||||
// a good in-progress mark on a blip).
|
||||
if (resp) next[sport] = resp;
|
||||
else if (prev[sport]) next[sport] = prev[sport];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
poll();
|
||||
const id = setInterval(poll, 60_000);
|
||||
return () => { cancelled = true; clearInterval(id); };
|
||||
}, [liveSportsKey]);
|
||||
|
||||
// Session 24 — switching sport resets the stat filter. The categories
|
||||
// differ per sport (Points vs Hits), so a stale "points" filter would
|
||||
// silently blank the MLB panels. Always land back on 'all'.
|
||||
@@ -647,6 +708,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
const gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
|
||||
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
|
||||
const pitcherMap = useMemo(() => buildPitcherMap(pitcherGames), [pitcherGames]);
|
||||
// A1 S11 — one merged live index across the polled sports.
|
||||
const liveIndex = useMemo(() => buildLiveIndex(Object.values(liveBySport)), [liveBySport]);
|
||||
|
||||
const filteredGames = useMemo(() => {
|
||||
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
|
||||
@@ -668,6 +731,17 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
.filter((g): g is SlateGame => g !== null);
|
||||
}, [games, searchQuery]);
|
||||
|
||||
// A1 S11 — live games with TRACKED props float to the top, ordered by
|
||||
// proximity-to-hit (pure sortLiveFirst; everything else keeps tip-off order).
|
||||
const orderedGames = useMemo<SlateGame[]>(() => {
|
||||
if (!liveIndex || liveIndex.count === 0) return filteredGames;
|
||||
return sortLiveFirst(filteredGames, (g: SlateGame) => (
|
||||
g.status === 'in'
|
||||
? gameLiveProximity(g.props, gradeIndex, liveIndex)
|
||||
: { tracked: false, proximity: 0 }
|
||||
)) as SlateGame[];
|
||||
}, [filteredGames, gradeIndex, liveIndex]);
|
||||
|
||||
// Session 25 — per-sport game counts for the tab labels, derived from
|
||||
// the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the
|
||||
// free ESPN schedule even when odds are empty. Counts only appear for
|
||||
@@ -943,10 +1017,10 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
|
||||
{dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
|
||||
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
{filteredGames.map((g, i) => (
|
||||
{orderedGames.map((g, i) => (
|
||||
<VyndrGameCard
|
||||
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`}
|
||||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability)}
|
||||
game={slateGameToCardData(g, gradeIndex, deltaIndex, pitcherMap, activeStat, viability, liveIndex)}
|
||||
preferredBooks={preferredBooks}
|
||||
onOpen={() => router.push('/scan')}
|
||||
/>
|
||||
|
||||
@@ -178,6 +178,12 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
const [linesExpanded, setLinesExpanded] = useState(false);
|
||||
const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]);
|
||||
const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible;
|
||||
// A1 S11 — LIVE SLATE MODE: any strip prop carrying live tracking shows the
|
||||
// once-per-card label. Grades locked pre-game NEVER change in-game.
|
||||
const isTracking = useMemo(
|
||||
() => (g.playerStrips || []).some((s) => (s.props || []).some((p) => p.live)),
|
||||
[g.playerStrips],
|
||||
);
|
||||
const bestLine = (pick: (ln: GameLine) => boolean, val: (ln: GameLine) => string) => {
|
||||
const ln = (g.lines || []).find(pick);
|
||||
return ln ? val(ln) : (g.lines && g.lines[0] ? val(g.lines[0]) : '—');
|
||||
@@ -296,7 +302,18 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
|
||||
archetype + horizontal stats + all graded props on one line); fall
|
||||
back to the legacy per-prop rows for callers that don't supply strips. */}
|
||||
<div style={{ padding: '13px 16px' }}>
|
||||
<SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
|
||||
<SectionHead style={{ marginBottom: 11 }}>
|
||||
GRADED PROPS
|
||||
{isTracking && (
|
||||
<span
|
||||
className="mono"
|
||||
title="Every grade locked before first pitch and never changes in-game — live marks are progress tracking against the locked line, not a re-grade"
|
||||
style={{ marginLeft: 8, fontSize: 9, fontWeight: 800, letterSpacing: '0.08em', color: 'var(--text-1, #7A7A8E)' }}
|
||||
>
|
||||
· TRACKING — READ LOCKED PRE-GAME
|
||||
</span>
|
||||
)}
|
||||
</SectionHead>
|
||||
{g.playerStrips && g.playerStrips.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{stripsToRender.map((ps, i) => (
|
||||
|
||||
@@ -38,6 +38,66 @@ export interface StripProp {
|
||||
// (true = cleared). Both absent → nothing renders.
|
||||
history?: Array<{ t: string; line: number }> | null;
|
||||
last10Dots?: boolean[] | null;
|
||||
// A1 S11 — the canonical stat key (live-tracking join; `stat` is the short
|
||||
// display label) + the live proto-outcome computed by lib/liveProgress.
|
||||
// GRADES NEVER CHANGE IN-GAME — `live` is TRACKING in the outcome slot.
|
||||
statType?: string;
|
||||
live?: {
|
||||
current: number;
|
||||
line: number | null;
|
||||
state: 'hit' | 'on_pace' | 'needs' | 'holding' | 'past' | string;
|
||||
label: string;
|
||||
needs?: number;
|
||||
progressLabel?: string | null;
|
||||
progressFraction?: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** A1 S11 — LIVE TRACKING mark (ROW-GRAMMAR §2 slot 6, proto-outcome).
|
||||
* `1/2 TB · ▲6th` + a small game-progress bar + the state chip. COLOR LAW:
|
||||
* green = hit/on-pace/holding, amber = needs-more/line-passed. NEVER red —
|
||||
* an in-progress prop has settled nothing. Data → mono, never glitches. */
|
||||
export function LiveTracker({ p }: { p: StripProp }) {
|
||||
const lv = p.live;
|
||||
if (!lv || p.outcome) return null;
|
||||
const green = lv.state === 'hit' || lv.state === 'on_pace' || lv.state === 'holding';
|
||||
const color = green ? 'var(--g-a, #00D4A0)' : 'var(--amber, #FFB347)';
|
||||
const filled = lv.state === 'hit';
|
||||
const frac = typeof lv.progressFraction === 'number' ? Math.min(1, Math.max(0, lv.progressFraction)) : null;
|
||||
const titles: Record<string, string> = {
|
||||
hit: 'The over has already cleared the locked line — settles when the game is final',
|
||||
on_pace: 'Current pace projects past the locked line',
|
||||
needs: 'Behind the locked line at the current pace',
|
||||
holding: 'The under holds if the count stays below the line — nothing is final until the game is',
|
||||
past: 'The count reached the line — the under can no longer clear; settles when final',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className="mono"
|
||||
title={`TRACKING — read locked pre-game. ${titles[lv.state] || ''}`}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 10.5 }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-0, #F0F0F5)' }}>
|
||||
{lv.current}/{lv.line != null ? lv.line : '—'} {p.stat}
|
||||
{lv.progressLabel ? <span style={{ color: 'var(--text-1, #7A7A8E)' }}> · {lv.progressLabel}</span> : null}
|
||||
</span>
|
||||
{frac != null && (
|
||||
<span aria-hidden style={{ width: 34, height: 3, borderRadius: 2, background: 'var(--bg-2, #12121A)', overflow: 'hidden', display: 'inline-block' }}>
|
||||
<span style={{ display: 'block', height: '100%', width: `${Math.round(frac * 100)}%`, background: color, opacity: 0.85 }} />
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9, fontWeight: 800, letterSpacing: '0.07em', padding: '1px 5px', borderRadius: 3,
|
||||
color: filled ? '#06060B' : color,
|
||||
background: filled ? color : 'transparent',
|
||||
border: `1px solid color-mix(in srgb, ${color} 55%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{lv.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** ROW-GRAMMAR §4 — ●/○ last-10 dot strip. Filled green = that game's stat
|
||||
@@ -398,9 +458,17 @@ export default function StatStrip({
|
||||
<GradeBadge grade={p.grade} size="sm" />
|
||||
)
|
||||
)}
|
||||
{/* ROW-GRAMMAR slot 6 — the outcome slot: live TRACKING
|
||||
proto-outcome while in-progress, settled chip once
|
||||
final (mutually exclusive — LiveTracker self-hides on
|
||||
outcome). A1 S11. */}
|
||||
{!p.dead && <LiveTracker p={p} />}
|
||||
<OutcomeChip p={p} />
|
||||
{!p.outcome && !p.dead && <ParlayBtn p={p} />}
|
||||
{!p.outcome && !p.dead && <BookItTeaser p={p} />}
|
||||
{/* ROW-GRAMMAR slot 7 — actions are suppressed once the
|
||||
game is LIVE (the pre-game market for the locked line
|
||||
is closed), dead, or settled. */}
|
||||
{!p.outcome && !p.dead && !p.live && <ParlayBtn p={p} />}
|
||||
{!p.outcome && !p.dead && !p.live && <BookItTeaser p={p} />}
|
||||
{p.gradedAt?.ago && (
|
||||
<span style={{ color: 'var(--text-2)' }}>
|
||||
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/* ============================================================
|
||||
VYNDR — live progress engine (A1 board, Session 11).
|
||||
|
||||
LIVE TRACKING math + the strip join. The read is locked pre-game;
|
||||
these marks are PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome
|
||||
slot (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6).
|
||||
GRADES NEVER CHANGE IN-GAME.
|
||||
|
||||
Plain CommonJS so the .tsx components import it (allowJs) AND the
|
||||
plain-JS Jest suite exercises every branch directly.
|
||||
|
||||
COLOR LAW (one meaning per color): green = on-pace / already-cleared /
|
||||
holding; amber = needs-more / line-passed caution. Red is RESERVED for
|
||||
settled-negative truth — an in-progress prop is NEVER red.
|
||||
|
||||
DATA SEMANTICS: a player not in the box has no live entry → no mark,
|
||||
never a fabricated 0. All numeric paths are strict-null.
|
||||
============================================================ */
|
||||
|
||||
const { nameKey } = require('./playerName');
|
||||
|
||||
/** Strict numeric read — null when absent/unparseable, never 0-by-default. */
|
||||
function numOrNull(v) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure prop-state math. { side, line, current, progress } → state or null.
|
||||
*
|
||||
* over, current > line → 'hit' (✓ the over has already cleared
|
||||
* — a counting stat cannot un-clear; still
|
||||
* TRACKING until the settle pass owns it)
|
||||
* over, projected to clear → 'on_pace' (green)
|
||||
* over, otherwise → 'needs' (amber, NEEDS N)
|
||||
* under, current < line → 'holding' (green HOLDS — an under is
|
||||
* never 'hit' until final)
|
||||
* under, current ≥ line → 'past' (amber LINE PASSED — not red;
|
||||
* nothing settles until the game is final)
|
||||
* current/line missing → null (absent beats wrong)
|
||||
*
|
||||
* NEEDS N beats a push on integer lines: N = floor(line) + 1 − current,
|
||||
* ceil'd for fractional stats (IP thirds) — over-strict amber beats an
|
||||
* over-claimed green. `progress` is the game fraction (innings/9, quarters/4);
|
||||
* on-pace = current / progress ≥ floor(line) + 1. No progress → no pace
|
||||
* judgement (stays NEEDS N — honest, not optimistic).
|
||||
*/
|
||||
function propState({ side, line, current, progress } = {}) {
|
||||
const ln = numOrNull(line);
|
||||
const cur = numOrNull(current);
|
||||
if (ln == null || cur == null) return null;
|
||||
const under = /^u/i.test(String(side || 'O'));
|
||||
if (!under) {
|
||||
if (cur > ln) return { state: 'hit', label: 'HIT ✓', needs: 0 };
|
||||
const clearAt = Math.floor(ln) + 1;
|
||||
const needs = Math.max(1, Math.ceil(clearAt - cur - 1e-9));
|
||||
const p = numOrNull(progress);
|
||||
const onPace = p != null && p > 0 && cur / p >= clearAt;
|
||||
return onPace
|
||||
? { state: 'on_pace', label: 'ON PACE', needs }
|
||||
: { state: 'needs', label: `NEEDS ${needs}`, needs };
|
||||
}
|
||||
if (cur < ln) return { state: 'holding', label: 'HOLDS' };
|
||||
return { state: 'past', label: 'LINE PASSED' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten /api/live/:sport response(s) → one join index:
|
||||
* { hasLive, count, players: { [nameKey]: { name, team, values, progress, gameId } } }.
|
||||
* Accepts a single response or an array (the Slate merges sports).
|
||||
*/
|
||||
function buildLiveIndex(responses) {
|
||||
const list = Array.isArray(responses) ? responses : [responses];
|
||||
const players = {};
|
||||
let hasLive = false;
|
||||
let count = 0;
|
||||
for (const resp of list) {
|
||||
if (!resp) continue;
|
||||
if (resp.hasLive) hasLive = true;
|
||||
for (const g of resp.games || []) {
|
||||
for (const [key, rec] of Object.entries(g.players || {})) {
|
||||
if (!rec) continue;
|
||||
players[key] = {
|
||||
name: rec.name,
|
||||
team: rec.team || null,
|
||||
values: rec.values || {},
|
||||
progress: g.progress || null,
|
||||
gameId: g.id,
|
||||
};
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { hasLive, count, players };
|
||||
}
|
||||
|
||||
/**
|
||||
* Join built player strips ↔ the live index (PURE). Only GRADED, unsettled,
|
||||
* non-dead props get `prop.live` — settled outcomes, dead reads and awaiting
|
||||
* rows are untouched, and a player absent from the box gets NO mark. The
|
||||
* state is computed against the LOCKED line (gradedAt.line when present) —
|
||||
* the read never re-grades.
|
||||
*/
|
||||
function attachLiveProgress(strips, liveIndex) {
|
||||
const idx = liveIndex && liveIndex.players ? liveIndex.players : null;
|
||||
if (!Array.isArray(strips) || !idx || Object.keys(idx).length === 0) return strips || [];
|
||||
return strips.map((strip) => {
|
||||
const entry = idx[nameKey(strip.player)];
|
||||
if (!entry) return strip;
|
||||
const fraction = entry.progress ? numOrNull(entry.progress.fraction) : null;
|
||||
let touched = false;
|
||||
const props = (strip.props || []).map((p) => {
|
||||
if (!p || !p.grade || p.outcome || p.dead || p.awaiting) return p;
|
||||
const st = String(p.statType || '').toLowerCase();
|
||||
if (!st) return p;
|
||||
const current = entry.values ? numOrNull(entry.values[st]) : null;
|
||||
if (current == null) return p; // not in the box for this stat — absent
|
||||
const line = p.gradedAt && numOrNull(p.gradedAt.line) != null ? p.gradedAt.line : p.line;
|
||||
const state = propState({ side: p.side, line, current, progress: fraction });
|
||||
if (!state) return p;
|
||||
touched = true;
|
||||
return {
|
||||
...p,
|
||||
live: {
|
||||
current,
|
||||
line: numOrNull(line),
|
||||
...state,
|
||||
progressLabel: entry.progress ? entry.progress.label || null : null,
|
||||
progressFraction: fraction,
|
||||
},
|
||||
};
|
||||
});
|
||||
return touched ? { ...strip, props } : strip;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Proximity-to-hit for one game's raw odds props (the Slate's sort key).
|
||||
* A prop is TRACKED when it's snapshot-graded AND its player has a live box
|
||||
* value for the stat. Proximity (overs only, per spec): current / needed-total
|
||||
* = current / (floor(line)+1), capped at 1; already-cleared overs count 1.
|
||||
* Unders count as tracked but contribute no over-proximity.
|
||||
* Returns { tracked, proximity }.
|
||||
*/
|
||||
function gameLiveProximity(rawProps, gradeIndex, liveIndex) {
|
||||
const idx = liveIndex && liveIndex.players ? liveIndex.players : null;
|
||||
if (!Array.isArray(rawProps) || !idx || !gradeIndex) return { tracked: false, proximity: 0 };
|
||||
let tracked = false;
|
||||
let proximity = 0;
|
||||
for (const p of rawProps) {
|
||||
if (!p || !p.player) continue;
|
||||
const stat = String(p.stat_type || p.stat || '').toLowerCase();
|
||||
const key = nameKey(p.player);
|
||||
const rec = gradeIndex[`${key}|${stat}`];
|
||||
if (!rec || !rec.grade) continue;
|
||||
const entry = idx[key];
|
||||
if (!entry) continue;
|
||||
const current = entry.values ? numOrNull(entry.values[stat]) : null;
|
||||
if (current == null) continue;
|
||||
tracked = true;
|
||||
const under = /^u/i.test(String(rec.direction || 'over'));
|
||||
if (under) continue;
|
||||
const line = rec.gradedAt && numOrNull(rec.gradedAt.line) != null ? rec.gradedAt.line : rec.line;
|
||||
const ln = numOrNull(line);
|
||||
if (ln == null) continue;
|
||||
const clearAt = Math.floor(ln) + 1;
|
||||
const frac = clearAt > 0 ? Math.min(1, current / clearAt) : 0;
|
||||
if (frac > proximity) proximity = frac;
|
||||
}
|
||||
return { tracked, proximity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable partition sort: items whose scorer says tracked float to the top,
|
||||
* ordered by proximity desc; everything else keeps its original order.
|
||||
*/
|
||||
function sortLiveFirst(items, scorer) {
|
||||
if (!Array.isArray(items) || typeof scorer !== 'function') return items || [];
|
||||
const scored = items.map((it, i) => {
|
||||
const s = scorer(it) || { tracked: false, proximity: 0 };
|
||||
return { it, i, tracked: !!s.tracked, proximity: numOrNull(s.proximity) || 0 };
|
||||
});
|
||||
return scored
|
||||
.sort((a, b) => {
|
||||
if (a.tracked !== b.tracked) return a.tracked ? -1 : 1;
|
||||
if (a.tracked && b.tracked && b.proximity !== a.proximity) return b.proximity - a.proximity;
|
||||
return a.i - b.i;
|
||||
})
|
||||
.map((x) => x.it);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
propState,
|
||||
buildLiveIndex,
|
||||
attachLiveProgress,
|
||||
gameLiveProximity,
|
||||
sortLiveFirst,
|
||||
numOrNull,
|
||||
};
|
||||
@@ -365,6 +365,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
|
||||
byPlayer[pk].props.push({
|
||||
stat: statShort(rec.stat_type || rec.stat),
|
||||
// A1 S11 — the CANONICAL stat key (live-tracking join; `stat` above is
|
||||
// the shortened display label and can't be joined on).
|
||||
statType: String(rec.stat_type || rec.stat || '').toLowerCase(),
|
||||
line: rec.line,
|
||||
side,
|
||||
grade: rec.grade,
|
||||
@@ -391,7 +394,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
|
||||
});
|
||||
} else {
|
||||
byPlayer[pk].props.push({
|
||||
stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true,
|
||||
stat: statShort(p.stat_type || p.stat),
|
||||
statType: String(p.stat_type || p.stat || '').toLowerCase(),
|
||||
line: p.line, side: '', grade: null, awaiting: true,
|
||||
book: p.book || null,
|
||||
bestBook: detectBestBook(p.books, p.direction || 'over', p.line),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user