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:
Kev
2026-07-12 00:45:29 -04:00
parent 14dc9cf270
commit b5d3fd14bb
18 changed files with 1666 additions and 18 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+21
View File
@@ -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 });
}
}
+80 -6
View File
@@ -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')}
/>
+18 -1
View File
@@ -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) => (
+70 -2
View File
@@ -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}` : ''}
+200
View File
@@ -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,
};
+6 -1
View File
@@ -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),
});