Files
vyndr/web/src/lib/liveProgress.js
T
builtbykev b5d3fd14bb 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>
2026-07-12 00:45:29 -04:00

201 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
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,
};