{stripsToRender.map((ps, i) => (
diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx
index 81885a7..9bda785 100644
--- a/web/src/components/vyndr/StatStrip.tsx
+++ b/web/src/components/vyndr/StatStrip.tsx
@@ -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
= {
+ 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 (
+
+
+ {lv.current}/{lv.line != null ? lv.line : '—'} {p.stat}
+ {lv.progressLabel ? · {lv.progressLabel} : null}
+
+ {frac != null && (
+
+
+
+ )}
+
+ {lv.label}
+
+
+ );
}
/** ROW-GRAMMAR §4 — ●/○ last-10 dot strip. Filled green = that game's stat
@@ -398,9 +458,17 @@ export default function StatStrip({
)
)}
+ {/* 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 && }
- {!p.outcome && !p.dead && }
- {!p.outcome && !p.dead && }
+ {/* 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 && }
+ {!p.outcome && !p.dead && !p.live && }
{p.gradedAt?.ago && (
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''}
diff --git a/web/src/lib/liveProgress.js b/web/src/lib/liveProgress.js
new file mode 100644
index 0000000..7a11961
--- /dev/null
+++ b/web/src/lib/liveProgress.js
@@ -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,
+};
diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js
index 03be4ce..12f4ea8 100644
--- a/web/src/lib/slateAdapter.js
+++ b/web/src/lib/slateAdapter.js
@@ -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),
});