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
+34 -1
View File
@@ -1,7 +1,40 @@
# VYNDR — Build State # VYNDR — Build State
## Last Updated ## Last Updated
2026-07-11 2026-07-12
## Session S11 (a1 board, 2026-07-12) — Live Tracking: the read locked, the game watched ✅
Spec: `specs/LIVE-TRACKING.md` (+ ROW-GRAMMAR §2/§3 S11 amendment).
2698 → **2757 tests** (229 suites), web build exit 0.
- **`liveTrackingService`** — MLB statsapi (`schedule?hydrate=linescore`
identifies Live games + inning in ONE call → boxscore per live game) + WNBA
ESPN (scoreboard 'in' → summary boxscore). Pure parsers built on REAL feed
shapes captured while PHI@DET was live (bottom 8th, 2026-07-11). LOCAL
`LIVE_BOX_FIELD` map (batting/pitching split + IP thirds — deliberately
decoupled from outcomeService.MLB_LOG_FIELD, same rule as settlement).
A player with empty box stats is ABSENT, never 0.
- **POLLING RULE** — `live:{sport}:{date}` TTL 90s, written cache-aside by
public `GET /api/live/:sport` (60/min, mounted in app.js + Next proxy
`app/api/live/[sport]`). Quota: 1 schedule + N-live-games boxscore calls
per 90s across ALL users; zero boxscore calls when nothing is live.
- **`lib/liveProgress.js`** (pure, CommonJS) — `propState` (over: HIT ✓ /
ON PACE / NEEDS N beats-the-push; under: HOLDS-IF — never hit until final,
LINE PASSED amber when exceeded; never red in-progress), `buildLiveIndex`,
`attachLiveProgress` (graded+unsettled+non-dead props only, joined on
nameKey + the new canonical `statType` strip field, state vs the LOCKED
line), `gameLiveProximity` + `sortLiveFirst`.
- **LIVE SLATE MODE** — Slate polls `/api/live/{sport}` every 60s ONLY while
live mlb/wnba games are on screen; live games with tracked props float to
the top by proximity-to-hit. `StatStrip.LiveTracker` renders
`3/1.5 TB · ▼8th` + game-progress bar + state chip in the ROW-GRAMMAR
OUTCOME slot (proto-outcome; actions suppressed while live); GameCard shows
"TRACKING — READ LOCKED PRE-GAME" once per live card. GRADES NEVER CHANGE
IN-GAME.
- **Acceptance (real feed)**: `Bryce Harper 3/1.5 TB · ▼8th → HIT ✓`,
`Cristopher Sánchez 1/2.5 ER · ▼8th → HOLDS` — full pipeline on the live
capture. Verify tonight: `curl -s https://vyndr.app/api/live/mlb | head -c 400`
(or `node -e "require('./src/services/liveTrackingService').getLiveTracking('mlb').then(o=>console.log(JSON.stringify(o).slice(0,400)))"`
on the box during a live window).
## Session S6 (a1 board, 2026-07-11) — Display: the full picture under the grammar ✅ ## Session S6 (a1 board, 2026-07-11) — Display: the full picture under the grammar ✅
Spec/law: `specs/ROW-GRAMMAR.md` (locked by `tests/unit/rowGrammar.test.js`). Spec/law: `specs/ROW-GRAMMAR.md` (locked by `tests/unit/rowGrammar.test.js`).
+34
View File
@@ -967,6 +967,40 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section).
weight files competed with Inter + CSS on slow 4G). Measure before/after weight files competed with Inter + CSS on slow 4G). Measure before/after
via PageSpeed post-deploy — not runnable from the box. via PageSpeed post-deploy — not runnable from the box.
## Live Tracking (Session S11, A1 board — non-obvious)
- **GRADES NEVER CHANGE IN-GAME.** `liveTrackingService` + `/api/live/:sport`
produce per-player CURRENT box values; `web/src/lib/liveProgress.js` turns
them into PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome slot
(`StatStrip.LiveTracker`). Spec = `specs/LIVE-TRACKING.md`; slot-6 + color
amendments are locked in `rowGrammar.test.js` — an in-progress prop is
NEVER red (green = hit/on-pace/holding, amber = needs/line-passed).
- **THREE MLB stat maps now exist on purpose** — do NOT merge them:
`featureCache.MLB_LOG_FIELD` (features), `outcomeService.MLB_LOG_FIELD`
(settlement, game-log rows pick ONE group by position), and
`liveTrackingService.LIVE_BOX_FIELD` (live boxscore carries BOTH
stats.batting AND stats.pitching per player → each stat names its group,
and innings_pitched parses in THIRDS: '5.2' = 5⅔ — parseFloat is wrong).
Adding an MLB stat_type = wire all three.
- **Absent beats zero, live edition:** a player not yet in the game has
EMPTY `stats.batting`/`stats.pitching` objects (MLB) or `didNotPlay`/empty
stats row (ESPN WNBA) → he is OMITTED from the live index. Never coerce to 0.
- **Quota shape:** `live:{sport}:{date}` TTL 90s is the shared window — 1
schedule (`hydrate=linescore`, so inning progress costs no extra call) + 1
boxscore per live game per 90s TOTAL across all users; zero boxscore calls
when nothing is live. The Slate polls `/api/live/{sport}` every 60s ONLY
while live mlb/wnba games are on screen. The route never fetches for
unwired sports (short-circuits before the service).
- **The strip join needs `statType`** — `buildPlayerStripsFromProps` now puts
the canonical lowercase stat key on every strip prop (`stat` is the short
display label like 'TB' and can't be joined on). attachLiveProgress only
touches graded, unsettled, non-dead props, and computes state vs the
LOCKED line (`gradedAt.line`). Under semantics are HOLDS-IF: an under is
never 'hit' until the settle pass; exceeded → amber LINE PASSED, not red.
- **Actions are suppressed while live** (`!p.live` on ParlayBtn/BookIt —
the pre-game market for a locked line closed at first pitch); the label
"TRACKING — READ LOCKED PRE-GAME" renders once per live card (GameCard,
dim — it's meta, not a caution signal).
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+114
View File
@@ -0,0 +1,114 @@
# VYNDR — LIVE TRACKING v1.0 (A1 board, Session 11)
### The read is locked pre-game. The game is watched. GRADES NEVER CHANGE IN-GAME — this layer is TRACKING, labeled as such, per the Ledger ethos.
## PRINCIPLES
1. **Zero out-of-pocket.** Free feeds only: statsapi.mlb.com (MLB live boxscore)
and the ESPN site API (WNBA summary boxscore). No new dependency, no quota key.
2. **Grades never change in-game.** The locked pre-game read is untouched;
live-progress marks are proto-outcomes rendered in the OUTCOME slot region
(ROW-GRAMMAR §2 slot 6). Every live card carries the label
"TRACKING — read locked pre-game" exactly once.
3. **One meaning per color.** Green = on-pace or already-cleared; amber =
needs-more / caution; red stays reserved for settled-negative truth
(miss / DEAD from NOT-IN-LINEUP). An in-progress prop is NEVER red.
4. **Only real box-line values.** A player not yet in the box score is ABSENT
(no mark, no bar, no zero). `Number(null) === 0` is the classic fabrication
bug — every numeric path uses strict null guards.
5. **An under is never "hit" until final.** Under semantics are HOLDS-IF: green
HOLDING while the count sits under the line, amber LINE PASSED once the
count reaches/passes it (counting stats can still be re-scored; nothing
settles until the game is final — the settle pass owns the truth).
6. **DEAD is not declared here.** An over that "can no longer hit" is only
honestly declarable for NOT-IN-LINEUP (S5, already shipped). Live tracking
tracks until final; it never over-claims.
## DATA SOURCES (real shapes captured live, 2026-07-11)
- **MLB** — `GET statsapi.mlb.com/api/v1/schedule?sportId=1&date=YYYY-MM-DD&hydrate=linescore`
identifies Live games (`status.abstractGameState === 'Live'`) AND carries the
per-game linescore (currentInning / inningState / scheduledInnings) in the
same call. Per live game: `GET /api/v1/game/{gamePk}/boxscore`
`teams.{home,away}.players.ID{personId}.stats.{batting,pitching}`. A player
who hasn't appeared has EMPTY stats objects → absent.
- **WNBA** — ESPN scoreboard (already cached by scheduleService) identifies
in-progress events (`status.type.state === 'in'`, `status.period`). Per live
event: `GET site.api.espn.com/.../wnba/summary?event={id}`
`boxscore.players[].statistics[0]` with parallel `keys` + per-athlete
`stats` string arrays. `didNotPlay` / empty stats → absent.
## STAT MAPS
- **MLB (LOCAL map, `LIVE_BOX_FIELD`)** — mirrors the idea of
outcomeService.MLB_LOG_FIELD but is deliberately LOCAL: the live boxscore
splits batting vs pitching into separate stat objects (the game log picks
ONE group by position), and innings_pitched must be parsed in thirds
('5.2' = 5⅔ = 5.667) for live pace math. Wired stats: hits, total_bases,
home_runs, rbi, runs, stolen_bases, doubles, walks (batting);
strikeouts, earned_runs, innings_pitched, outs, hits_allowed (pitching).
- **WNBA (`WNBA_BOX_KEY`)** — points, rebounds, assists, steals, blocks,
turnovers, threes (made, parsed from "M-A"), pra (points+rebounds+assists,
computed only when all three components are present).
## PROP STATE (pure math, `web/src/lib/liveProgress.js`)
`propState({ side, line, current, progress })`
- over, current > line → `hit` (✓ HIT — the over has already cleared; a
counting stat cannot un-clear; still labeled TRACKING until the settle pass)
- over, not cleared → needs `N = floor(line) + 1 current` (beats a push on
integer lines); `on_pace` (green) when `current / progress ≥ floor(line)+1`,
else `needs` (amber, "NEEDS N")
- under, current < line → `holding` (green HOLDS chip — never ✓ until final)
- under, current ≥ line → `past` (amber LINE PASSED — not red; nothing settled)
- current == null → NO state (absent beats wrong)
Progress fraction: MLB `(inning (top ? 1 : 0.5)) / scheduledInnings`;
WNBA `(period 0.5) / 4`, clamped to [0, 1].
## ENDPOINTS
- **`GET /api/live/:sport`** (public, rate-limited 60/min, mounted in app.js)
`{ sport, date, hasLive, updated_at, games: [{ id, home, away, progress:
{ label, fraction, inning|period, half }, players: { [nameKey]: { name,
values: { stat_type: number } } } }] }`. mlb + wnba only; other sports →
`{ hasLive: false, games: [] }`.
- **POLLING RULE:** the route reads `live:{sport}:{date}` (TTL 90s). On a cache
MISS it consults the day's schedule; ONLY when the schedule shows live games
does it fetch boxscores. Quota math: 1 schedule call + N boxscore calls per
90s window across ALL users (shared cache), during live windows only — zero
boxscore calls when nothing is live.
- **Next proxy** `web/src/app/api/live/[sport]/route.ts` (S25 rule — Express
is not browser-reachable).
## FRONTEND (LIVE SLATE MODE)
- Slate polls `/api/live/{sport}` every 60s ONLY while live games are on
screen (mlb/wnba, status 'in').
- `liveProgress.attachLiveProgress(strips, liveIndex)` (pure) joins built
player strips to live values by nameKey + canonical statType; only graded,
unsettled, non-dead props get `prop.live`.
- StatStrip renders the live mark in the OUTCOME slot region:
`1/2 TB · ▲6th` + a small game-progress bar + the state chip
(ON PACE green / NEEDS 2 amber / HIT ✓ green filled / HOLDS green /
LINE PASSED amber). Actions (parlay +, BOOK IT) are suppressed on live
props — the pre-game market for the locked line is closed.
- Live games with tracked props float to the top of the slate, ordered by
proximity-to-hit (max over-fraction `current / needed-total`).
- The card label "TRACKING — read locked pre-game" renders once per live card.
## ACCEPTANCE CRITERIA
1. Parsers reproduce per-player values from REAL captured feed fixtures; a
player absent from the box yields NO entry (never 0).
2. propState covers: over cleared / on-pace / needs-N / under-holds /
under-passed / absent — unit-tested.
3. `/api/live/:sport` cache behavior: hit → no fetch; miss + live games →
boxscore fetch + cache write; miss + no live games → schedule check only.
4. attachLiveProgress joins strips ↔ live index and never touches settled,
dead, or ungraded props.
5. Full jest suite green from the worktree; `cd web && npm run build` exit 0.
6. If a game is genuinely live during the build, the service runs once against
the real feed and the report shows a real tracked line.
## TEST PLAN
- `tests/unit/liveTrackingService.test.js` — MLB boxscore/linescore + WNBA
summary parsers on real-shape fixtures; stat resolution incl. IP thirds;
absent-player semantics; live-game identification from schedules.
- `tests/unit/liveProgress.test.js` — propState math table; progress
fractions; attachLiveProgress; proximity sort.
- `tests/integration/liveRoute.test.js` — cache hit / miss+live / miss+idle /
unknown sport, with injected deps (zero network).
— LIVE-TRACKING v1.0 · July 2026 —
+9 -2
View File
@@ -23,8 +23,8 @@ graded prop inline) reads left → right in this fixed order:
| 3 stat+line+side | `TB O1.5` | white, mono — the subject of the row | | 3 stat+line+side | `TB O1.5` | white, mono — the subject of the row |
| 4 market context | best-price dot · movement chip (STEAM ▲ / VALUE ▲) | what the MARKET is doing, before what the model says | | 4 market context | best-price dot · movement chip (STEAM ▲ / VALUE ▲) | what the MARKET is doing, before what the model says |
| 5 model output | revision strikethrough (original grade) → current grade badge | history then present: `A̶ B` reads "was A, now B" | | 5 model output | revision strikethrough (original grade) → current grade badge | history then present: `A̶ B` reads "was A, now B" |
| 6 outcome | settled chip (✓ HIT / ✕ MISS / PUSH + actual) | once settled, actions (slot 7) disappear — the bet is over | | 6 outcome | live TRACKING mark (`1/2 TB · ▲6th` + progress bar + state chip), then settled chip (✓ HIT / ✕ MISS / PUSH + actual) | S11 amendment: live progress is a PROTO-OUTCOME and lives in this slot; the two are mutually exclusive (a settled prop shows only the settled chip). Once settled, actions (slot 7) disappear — the bet is over |
| 7 actions | parlay `+` · BOOK IT ⟶ | suppressed when dead or settled | | 7 actions | parlay `+` · BOOK IT ⟶ | suppressed when live, dead or settled (the pre-game market for a locked line closes at first pitch) |
| 8 provenance | `Graded 2h ago at -115` | the receipt, always last | | 8 provenance | `Graded 2h ago at -115` | the receipt, always last |
**Sub-line (stat + market context, one line under the row, in this order):** **Sub-line (stat + market context, one line under the row, in this order):**
@@ -49,6 +49,13 @@ Red is never used for mere movement-against or a lower price — those are amber
the net move is TOWARD the graded side, amber when net AGAINST, dim when flat — the net move is TOWARD the graded side, amber when net AGAINST, dim when flat —
never red (nothing has settled). never red (nothing has settled).
**S11 amendment — live TRACKING marks (slot 6 proto-outcomes):** green =
already-cleared over (HIT ✓, filled), on-pace over, or an under still holding;
amber = NEEDS N or an under whose line has been passed. An IN-PROGRESS prop is
NEVER red — red stays reserved for the settled miss and the NOT-IN-LINEUP dead
read. The read is locked pre-game and never re-grades; every live card carries
"TRACKING — read locked pre-game" exactly once (specs/LIVE-TRACKING.md).
## 4. MARK LAW — the micro-vocabulary ## 4. MARK LAW — the micro-vocabulary
- **● / ○ dot strip** — last 10 games vs TONIGHT'S locked line, newest first. - **● / ○ dot strip** — last 10 games vs TONIGHT'S locked line, newest first.
Filled green = that game's stat cleared the line; hollow dim = it didn't. Filled green = that game's stat cleared the line; hollow dim = it didn't.
+4
View File
@@ -161,6 +161,10 @@ const snapshotReadRoutes = require('./routes/snapshot');
app.use('/api/snapshot', snapshotReadRoutes); app.use('/api/snapshot', snapshotReadRoutes);
// Session 51 — Team Hub (roster + archetypes + graded props). Public, cached. // Session 51 — Team Hub (roster + archetypes + graded props). Public, cached.
app.use('/api/team', require('./routes/team')); app.use('/api/team', require('./routes/team'));
// A1 Session 11 — LIVE TRACKING: current box-line values for in-progress
// games (free statsapi/ESPN, shared 90s cache). Grades never change in-game
// — this is tracking, labeled as such.
app.use('/api/live', require('./routes/live'));
// Session 55 — self-learning loop: the system's rolling accuracy record // Session 55 — self-learning loop: the system's rolling accuracy record
// (settled snapshot grades vs real results). Public, cache-only. // (settled snapshot grades vs real results). Public, cache-only.
app.use('/api/accuracy', require('./routes/accuracy')); app.use('/api/accuracy', require('./routes/accuracy'));
+44
View File
@@ -0,0 +1,44 @@
'use strict';
/**
* GET /api/live/:sport — LIVE TRACKING read (A1 board, Session 11).
*
* Public, rate-limited 60/min. Cache-aside via liveTrackingService:
* reads `live:{sport}:{date}` (TTL 90s); on a miss it consults the day's
* schedule and fetches boxscores ONLY when games are actually live — so the
* total upstream cost is 1 schedule call + N-live-games boxscore calls per
* 90s window across ALL users, and zero boxscore calls when nothing is live
* (specs/LIVE-TRACKING.md POLLING RULE).
*
* This is TRACKING, not re-grading — grades are locked pre-game and this
* endpoint never touches them.
*/
const express = require('express');
const { createRateLimit } = require('../middleware/rateLimit');
const { getLiveTracking } = require('../services/liveTrackingService');
const router = express.Router();
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const LIVE_SPORTS = new Set(['mlb', 'wnba']);
router.get('/:sport', async (req, res) => {
const sport = String(req.params.sport || '').toLowerCase();
if (!LIVE_SPORTS.has(sport)) {
// Honest empty for sports without a free live box feed — never an error.
return res.json({ sport, hasLive: false, games: [] });
}
try {
const data = await getLiveTracking(sport);
// Short CDN/browser cache — matches the 90s shared window without
// holding a live slate stale for long.
res.set('Cache-Control', 'public, max-age=30');
return res.json(data);
} catch (err) {
console.error('[live]', err.message);
return res.status(200).json({ sport, hasLive: false, games: [] });
}
});
module.exports = router;
+368
View File
@@ -0,0 +1,368 @@
'use strict';
/**
* liveTrackingService — LIVE TRACKING (A1 board, Session 11).
*
* The read is locked pre-game; the game is watched. This service produces the
* per-player CURRENT box-line values for in-progress games so the slate can
* render live progress marks — proto-outcomes in the OUTCOME slot region
* (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6). GRADES NEVER
* CHANGE IN-GAME.
*
* Sources (both FREE, zero out-of-pocket):
* MLB — statsapi.mlb.com: schedule?hydrate=linescore identifies Live games
* AND carries inning progress in ONE call; then
* /api/v1/game/{gamePk}/boxscore per live game.
* WNBA — ESPN scoreboard (state 'in' + period) identifies live events; then
* the ESPN summary?event= boxscore per live event.
*
* POLLING RULE / quota math: getLiveTracking caches `live:{sport}:{date}` for
* LIVE_TTL (90s). The cost during live windows is 1 schedule call + N boxscore
* calls per 90s ACROSS ALL USERS (shared cache); when nothing is live the
* cached { hasLive: false } envelope means one schedule check per 90s while
* anyone polls, and ZERO boxscore calls. The frontend only polls while live
* games are on screen.
*
* DATA SEMANTICS: only real box-line values. A player who hasn't appeared has
* EMPTY stats objects in the MLB boxscore (or didNotPlay / an empty stats row
* on ESPN) → he is ABSENT from the output, never 0.
*
* Everything is injectable (fetchJson, cacheGet, cacheSet) → parsers and the
* whole refresh cycle are unit-tested on REAL captured feed shapes with zero
* network (fixtures captured live 2026-07-11).
*/
const { nameKey } = require('../utils/playerName');
const LIVE_TTL = 90; // seconds — the shared polling window
const HTTP_TIMEOUT_MS = 10_000;
const MLB_BASE = 'https://statsapi.mlb.com/api/v1';
const ESPN_WNBA_BASE = 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba';
// ---------------------------------------------------------------------------
// MLB stat map — VYNDR stat_type → { group, field } in a LIVE boxscore player.
//
// This is a LOCAL map on purpose (same rule as outcomeService.MLB_LOG_FIELD):
// settlement reads the post-game GAME LOG, where statsapi has already picked
// ONE stat group for the row by position. The LIVE boxscore instead carries
// BOTH `stats.batting` and `stats.pitching` per player, so each stat must name
// its group explicitly — and innings_pitched must be parsed in thirds
// ('5.2' = 5⅔ ≈ 5.667) for pace math, which parseFloat gets wrong. Coupling
// the two maps would let a settlement-side change silently break live pace
// (or vice versa). If you add an MLB stat_type, wire it HERE for tracking and
// in outcomeService.MLB_LOG_FIELD for settlement.
// ---------------------------------------------------------------------------
const LIVE_BOX_FIELD = {
hits: { group: 'batting', field: 'hits' },
total_bases: { group: 'batting', field: 'totalBases' },
home_runs: { group: 'batting', field: 'homeRuns' },
rbi: { group: 'batting', field: 'rbi' },
runs: { group: 'batting', field: 'runs' },
stolen_bases: { group: 'batting', field: 'stolenBases' },
doubles: { group: 'batting', field: 'doubles' },
triples: { group: 'batting', field: 'triples' },
walks: { group: 'batting', field: 'baseOnBalls' },
// Pitcher props. `strikeouts` is the PITCHER prop in the VYNDR vocabulary
// (pitcher_strikeouts market) — resolved from stats.pitching.
strikeouts: { group: 'pitching', field: 'strikeOuts' },
earned_runs: { group: 'pitching', field: 'earnedRuns' },
hits_allowed: { group: 'pitching', field: 'hits' },
outs: { group: 'pitching', field: 'outs' },
innings_pitched: { group: 'pitching', field: 'inningsPitched', parse: ipToDecimal },
};
/** MLB innings string in thirds → decimal ('5.2' = 5 + 2/3). Null-strict. */
function ipToDecimal(v) {
if (v == null || v === '') return null;
const [whole, partial] = String(v).split('.');
const w = Number(whole);
if (!Number.isFinite(w)) return null;
const p = Number(partial) || 0;
return w + p / 3;
}
/** Strict numeric read — null when absent/unparseable, NEVER a fabricated 0. */
function numOrNull(v) {
if (v == null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
// ---------------------------------------------------------------------------
// MLB parsers (pure)
// ---------------------------------------------------------------------------
/**
* Pure: statsapi schedule JSON (hydrate=linescore) → live games only:
* [{ gamePk, home, away, progress }]. progress = { label, fraction, inning,
* half, scheduledInnings }. Games without a parseable linescore still list
* (progress null) — the boxscore values are real either way.
*/
function parseMlbLiveSchedule(scheduleJson) {
const games = ((scheduleJson || {}).dates || []).flatMap((d) => d.games || []);
return games
.filter((g) => g && g.status && g.status.abstractGameState === 'Live')
.map((g) => ({
gamePk: g.gamePk ?? null,
home: g.teams?.home?.team?.name ?? null,
away: g.teams?.away?.team?.name ?? null,
progress: mlbProgress(g.linescore),
}));
}
/**
* Pure: an MLB linescore → { label, fraction, inning, half, scheduledInnings }.
* Fraction = (inning (top ? 1 : 0.5)) / scheduled — mid-inning estimate for
* pace math (Bottom 8th of 9 → 7.5/9 ≈ 0.833). Label uses ▲ (top) / ▼ (bottom):
* '▲6th' / '▼8th'. Null when the feed carries no inning yet.
*/
function mlbProgress(linescore) {
const ls = linescore || {};
const inning = numOrNull(ls.currentInning);
if (inning == null) return null;
const scheduled = numOrNull(ls.scheduledInnings) || 9;
// inningState: Top | Middle | Bottom | End. Middle/End sit between halves —
// count them as the completed half (top done → same fraction as bottom start).
const state = String(ls.inningState || (ls.isTopInning ? 'Top' : 'Bottom')).toLowerCase();
const top = state === 'top';
const fraction = Math.min(1, Math.max(0, (inning - (top ? 1 : 0.5)) / scheduled));
const ord = ordinal(inning);
return {
label: `${top ? '▲' : '▼'}${ord}`,
fraction,
inning,
half: top ? 'top' : 'bottom',
scheduledInnings: scheduled,
};
}
function ordinal(n) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
}
/**
* Pure: an MLB live boxscore → { [nameKey]: { name, team, values } } where
* `values` maps VYNDR stat_type → current number for every wired stat the
* player has REAL box data for. A player with empty batting AND pitching
* objects has not appeared → OMITTED entirely (absent, never 0).
*/
function parseMlbBoxscore(boxJson) {
const out = {};
const teams = (boxJson || {}).teams || {};
for (const side of ['home', 'away']) {
const t = teams[side] || {};
const teamName = t.team?.name ?? null;
const players = t.players || {};
for (const key of Object.keys(players)) {
const p = players[key];
const name = p?.person?.fullName;
if (!name) continue;
const batting = p.stats?.batting;
const pitching = p.stats?.pitching;
const hasBatting = batting && Object.keys(batting).length > 0;
const hasPitching = pitching && Object.keys(pitching).length > 0;
if (!hasBatting && !hasPitching) continue; // not in the game yet — absent
const values = {};
for (const [statType, m] of Object.entries(LIVE_BOX_FIELD)) {
const groupObj = m.group === 'batting' ? (hasBatting ? batting : null) : (hasPitching ? pitching : null);
if (!groupObj) continue;
const raw = groupObj[m.field];
const val = m.parse ? m.parse(raw) : numOrNull(raw);
if (val != null) values[statType] = val;
}
if (Object.keys(values).length === 0) continue;
out[nameKey(name)] = { name, team: teamName, values };
}
}
return out;
}
// ---------------------------------------------------------------------------
// WNBA parsers (pure) — ESPN site API
// ---------------------------------------------------------------------------
// VYNDR stat_type → the ESPN boxscore `keys` entry. `threes` is the made count
// parsed from the "M-A" pair; `pra` is computed from components (never read).
const WNBA_BOX_KEY = {
points: 'points',
rebounds: 'rebounds',
assists: 'assists',
steals: 'steals',
blocks: 'blocks',
turnovers: 'turnovers',
threes: 'threePointFieldGoalsMade-threePointFieldGoalsAttempted',
};
/**
* Pure: ESPN scoreboard JSON → live events only:
* [{ id, home, away, progress }]. progress fraction = (period 0.5) / 4,
* clamped (OT caps at 1) — mid-period estimate, label 'Q{n}' / 'OT'.
*/
function parseWnbaLiveScoreboard(scoreboardJson) {
const events = (scoreboardJson || {}).events || [];
return events
.filter((e) => e?.status?.type?.state === 'in')
.map((e) => {
const comp = e.competitions?.[0] || {};
const competitors = comp.competitors || [];
const home = competitors.find((c) => c.homeAway === 'home');
const away = competitors.find((c) => c.homeAway === 'away');
const period = numOrNull(e.status?.period);
const progress = period == null ? null : {
label: period > 4 ? (period === 5 ? 'OT' : `${period - 4}OT`) : `Q${period}`,
fraction: Math.min(1, Math.max(0, (period - 0.5) / 4)),
period,
};
return {
id: String(e.id),
home: home?.team?.displayName ?? null,
away: away?.team?.displayName ?? null,
progress,
};
});
}
/**
* Pure: an ESPN summary boxscore → { [nameKey]: { name, team, values } }.
* ESPN ships per-team `statistics[0]` with a parallel `keys` array and
* per-athlete `stats` string arrays. didNotPlay or an empty stats row →
* the player has not appeared → OMITTED (absent, never 0). `pra` is computed
* only when points, rebounds AND assists are all present.
*/
function parseWnbaBoxscore(summaryJson) {
const out = {};
const teams = (summaryJson || {}).boxscore?.players || [];
for (const t of teams) {
const teamName = t.team?.displayName ?? t.team?.abbreviation ?? null;
const block = (t.statistics || [])[0];
if (!block || !Array.isArray(block.keys) || !Array.isArray(block.athletes)) continue;
const idx = {};
block.keys.forEach((k, i) => { idx[k] = i; });
for (const a of block.athletes) {
const name = a?.athlete?.displayName;
if (!name) continue;
const row = a.stats;
if (a.didNotPlay || !Array.isArray(row) || row.length === 0) continue; // absent
const values = {};
for (const [statType, key] of Object.entries(WNBA_BOX_KEY)) {
const i = idx[key];
if (i == null || row[i] == null) continue;
const raw = String(row[i]);
const val = statType === 'threes' ? numOrNull(raw.split('-')[0]) : numOrNull(raw);
if (val != null) values[statType] = val;
}
if (values.points != null && values.rebounds != null && values.assists != null) {
values.pra = values.points + values.rebounds + values.assists;
}
if (Object.keys(values).length === 0) continue;
out[nameKey(name)] = { name, team: teamName, values };
}
}
return out;
}
// ---------------------------------------------------------------------------
// Fetch + cache layer
// ---------------------------------------------------------------------------
async function defaultFetchJson(url) {
const axios = require('axios');
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
return res && res.data ? res.data : null;
}
function resolveDeps(opts = {}) {
return {
fetchJson: opts.fetchJson || defaultFetchJson,
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
};
}
/** Today in ET (sports days roll over on ET, not UTC). */
function todayET() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
}
/**
* Build the fresh live envelope for one sport (network path — called only on
* a cache miss). One schedule/scoreboard call; boxscore calls ONLY for games
* the schedule marks live. Per-game failures degrade to an empty players map
* for that game — the rest of the slate still tracks.
*/
async function fetchLiveTracking(sport, date, deps) {
const sp = String(sport || '').toLowerCase();
if (sp === 'mlb') {
const sched = await deps.fetchJson(`${MLB_BASE}/schedule?sportId=1&date=${date}&hydrate=linescore`);
const live = parseMlbLiveSchedule(sched);
const games = [];
for (const g of live) {
let players = {};
try {
const box = await deps.fetchJson(`${MLB_BASE}/game/${g.gamePk}/boxscore`);
players = parseMlbBoxscore(box);
} catch (e) {
console.warn('[liveTracking] mlb boxscore failed:', g.gamePk, e.message);
}
games.push({ id: String(g.gamePk), home: g.home, away: g.away, progress: g.progress, players });
}
return { sport: sp, date, hasLive: games.length > 0, games };
}
if (sp === 'wnba') {
const board = await deps.fetchJson(`${ESPN_WNBA_BASE}/scoreboard`);
const live = parseWnbaLiveScoreboard(board);
const games = [];
for (const g of live) {
let players = {};
try {
const summary = await deps.fetchJson(`${ESPN_WNBA_BASE}/summary?event=${encodeURIComponent(g.id)}`);
players = parseWnbaBoxscore(summary);
} catch (e) {
console.warn('[liveTracking] wnba summary failed:', g.id, e.message);
}
games.push({ id: g.id, home: g.home, away: g.away, progress: g.progress, players });
}
return { sport: sp, date, hasLive: games.length > 0, games };
}
// Other sports: no free live box feed wired — honest empty, never a guess.
return { sport: sp, date, hasLive: false, games: [] };
}
/**
* Cache-aside live read (the route's entrypoint). `live:{sport}:{date}` TTL
* 90s — the shared polling window. A no-live result is ALSO cached for 90s so
* idle polling costs one schedule check per window, zero boxscore calls.
* Never throws: any failure returns an empty envelope.
*/
async function getLiveTracking(sport, opts = {}) {
const deps = resolveDeps(opts);
const sp = String(sport || '').toLowerCase();
const date = opts.date || todayET();
const key = `live:${sp}:${date}`;
try {
const cached = await deps.cacheGet(key);
if (cached !== null) return cached;
const fresh = await fetchLiveTracking(sp, date, deps);
fresh.updated_at = new Date().toISOString();
await deps.cacheSet(key, fresh, LIVE_TTL);
return fresh;
} catch (err) {
console.warn(`[liveTracking] ${sp} failed:`, err.message);
return { sport: sp, date, hasLive: false, games: [], error: 'unavailable' };
}
}
module.exports = {
getLiveTracking,
fetchLiveTracking,
parseMlbLiveSchedule,
parseMlbBoxscore,
parseWnbaLiveScoreboard,
parseWnbaBoxscore,
mlbProgress,
__internals: { LIVE_BOX_FIELD, WNBA_BOX_KEY, ipToDecimal, numOrNull, ordinal, LIVE_TTL, todayET, MLB_BASE, ESPN_WNBA_BASE },
};
+69
View File
@@ -0,0 +1,69 @@
'use strict';
/**
* GET /api/live/:sport (A1 Session 11) — route wiring over the cache-aside
* service. The service's cache behavior is unit-tested with injected deps
* (liveTrackingService.test.js); here we lock the route contract: sport
* gating BEFORE the service, envelope pass-through, and fail-open 200s.
*/
const express = require('express');
const request = require('supertest');
jest.mock('../../src/services/liveTrackingService', () => ({
getLiveTracking: jest.fn(),
}));
const { getLiveTracking } = require('../../src/services/liveTrackingService');
function mountLive() {
delete require.cache[require.resolve('../../src/routes/live')];
const liveRoutes = require('../../src/routes/live');
const app = express();
app.use('/api/live', liveRoutes);
return app;
}
beforeEach(() => jest.clearAllMocks());
describe('GET /api/live/:sport', () => {
it('returns the service envelope for a wired sport (mlb)', async () => {
const envelope = {
sport: 'mlb', date: '2026-07-11', hasLive: true, updated_at: 'x',
games: [{ id: '824249', progress: { label: '▼8th', fraction: 0.83 }, players: { 'bryce harper': { name: 'Bryce Harper', values: { total_bases: 3 } } } }],
};
getLiveTracking.mockResolvedValue(envelope);
const res = await request(mountLive()).get('/api/live/mlb');
expect(res.status).toBe(200);
expect(res.body).toEqual(envelope);
expect(getLiveTracking).toHaveBeenCalledWith('mlb');
expect(res.headers['cache-control']).toContain('max-age=30');
});
it('wnba is wired', async () => {
getLiveTracking.mockResolvedValue({ sport: 'wnba', hasLive: false, games: [] });
const res = await request(mountLive()).get('/api/live/wnba');
expect(res.status).toBe(200);
expect(getLiveTracking).toHaveBeenCalledWith('wnba');
});
it('an unwired sport short-circuits WITHOUT touching the service (no quota risk)', async () => {
const res = await request(mountLive()).get('/api/live/nba');
expect(res.status).toBe(200);
expect(res.body).toEqual({ sport: 'nba', hasLive: false, games: [] });
expect(getLiveTracking).not.toHaveBeenCalled();
});
it('a service failure fails OPEN — 200 with an honest empty envelope', async () => {
getLiveTracking.mockRejectedValue(new Error('redis down'));
const res = await request(mountLive()).get('/api/live/mlb');
expect(res.status).toBe(200);
expect(res.body).toEqual({ sport: 'mlb', hasLive: false, games: [] });
});
it('sport param is case-insensitive', async () => {
getLiveTracking.mockResolvedValue({ sport: 'mlb', hasLive: false, games: [] });
const res = await request(mountLive()).get('/api/live/MLB');
expect(res.status).toBe(200);
expect(getLiveTracking).toHaveBeenCalledWith('mlb');
});
});
+218
View File
@@ -0,0 +1,218 @@
'use strict';
/**
* lib/liveProgress (A1 Session 11) — pure prop-state math + the strip join.
* The read is locked pre-game; these are proto-outcomes for the ROW-GRAMMAR
* outcome slot. Color law: green = hit/on-pace/holding, amber = needs/past,
* NEVER red in-progress.
*/
const {
propState,
buildLiveIndex,
attachLiveProgress,
gameLiveProximity,
sortLiveFirst,
} = require('../../web/src/lib/liveProgress');
describe('propState — over semantics', () => {
test('over already cleared → HIT ✓', () => {
expect(propState({ side: 'O', line: 1.5, current: 2, progress: 0.5 }))
.toEqual({ state: 'hit', label: 'HIT ✓', needs: 0 });
});
test('current == integer line is NOT a hit (push at best) → needs 2 to beat it', () => {
const s = propState({ side: 'O', line: 2, current: 2, progress: 0.9 });
expect(s.state).not.toBe('hit');
expect(s.needs).toBe(1); // needs 1 more to reach 3 (> 2)
});
test('on pace: projection clears floor(line)+1', () => {
// 1 TB at 50% of the game → projects 2 = clears O1.5.
expect(propState({ side: 'O', line: 1.5, current: 1, progress: 0.5 }).state).toBe('on_pace');
});
test('behind pace → NEEDS N amber state', () => {
const s = propState({ side: 'O', line: 1.5, current: 0, progress: 0.833 });
expect(s.state).toBe('needs');
expect(s.label).toBe('NEEDS 2');
expect(s.needs).toBe(2);
});
test('NEEDS N beats the push on integer lines', () => {
const s = propState({ side: 'over', line: 2, current: 1, progress: 0.9 });
expect(s.needs).toBe(2); // to reach 3, not the push at 2
});
test('no progress → no pace optimism, stays NEEDS', () => {
const s = propState({ side: 'O', line: 1.5, current: 1, progress: null });
expect(s.state).toBe('needs');
expect(s.needs).toBe(1);
});
test('fractional stats (IP) ceil the needs count — over-strict beats over-claimed', () => {
// 3⅔ IP with 70% of the game gone projects 5.24 < clear-at-6 → behind.
const s = propState({ side: 'O', line: 5.5, current: 3 + 2 / 3, progress: 0.7 });
expect(s.state).toBe('needs');
expect(s.needs).toBe(3); // to 6, from 3.667 → ceil(2.33)
});
});
describe('propState — under HOLDS-IF semantics (never hit until final)', () => {
test('count below the line → holding (green), NOT hit', () => {
const s = propState({ side: 'U', line: 1.5, current: 0, progress: 0.9 });
expect(s.state).toBe('holding');
expect(s.label).toBe('HOLDS');
});
test('count reached/passed the line → past (amber), NOT a settled miss', () => {
expect(propState({ side: 'U', line: 1.5, current: 2, progress: 0.5 }).state).toBe('past');
expect(propState({ side: 'under', line: 2, current: 2, progress: 0.5 }).state).toBe('past');
});
});
describe('propState — data semantics', () => {
test('absent current or line → null (never a fabricated state)', () => {
expect(propState({ side: 'O', line: 1.5, current: null, progress: 0.5 })).toBeNull();
expect(propState({ side: 'O', line: null, current: 2, progress: 0.5 })).toBeNull();
expect(propState({})).toBeNull();
});
test('a REAL zero is a real value, not absent', () => {
expect(propState({ side: 'U', line: 0.5, current: 0, progress: 0.5 }).state).toBe('holding');
});
});
// ── Join fixtures ────────────────────────────────────────────────────
const liveResponse = {
sport: 'mlb',
hasLive: true,
games: [{
id: '824249',
home: 'Detroit Tigers',
away: 'Philadelphia Phillies',
progress: { label: '▼8th', fraction: 7.5 / 9 },
players: {
'bryce harper': { name: 'Bryce Harper', team: 'Philadelphia Phillies', values: { hits: 2, total_bases: 3 } },
'casey mize': { name: 'Casey Mize', team: 'Detroit Tigers', values: { strikeouts: 5, earned_runs: 3 } },
},
}],
};
const strips = [
{
player: 'Bryce Harper', team: 'Philadelphia Phillies',
props: [
{ stat: 'TB', statType: 'total_bases', line: 1.5, side: 'O', grade: 'A-', gradedAt: { line: 1.5 } },
{ stat: 'HR', statType: 'home_runs', line: 0.5, side: 'O', grade: 'B' }, // no box value → absent
],
},
{
player: 'Casey Mize', team: 'Detroit Tigers',
props: [
{ stat: 'Ks', statType: 'strikeouts', line: 5.5, side: 'O', grade: 'B+', gradedAt: { line: 5.5 } },
{ stat: 'ER', statType: 'earned_runs', line: 2.5, side: 'U', grade: 'A', gradedAt: { line: 2.5 } },
],
},
{
player: 'Kyle Schwarber', team: 'Philadelphia Phillies',
props: [{ stat: 'HR', statType: 'home_runs', line: 0.5, side: 'O', grade: 'B-' }], // not in the box at all
},
];
describe('buildLiveIndex', () => {
test('flattens response(s) into a nameKey join index', () => {
const idx = buildLiveIndex(liveResponse);
expect(idx.hasLive).toBe(true);
expect(idx.count).toBe(2);
expect(idx.players['bryce harper'].values.total_bases).toBe(3);
expect(idx.players['casey mize'].progress.label).toBe('▼8th');
});
test('merges an array of per-sport responses', () => {
const idx = buildLiveIndex([liveResponse, { hasLive: false, games: [] }, null]);
expect(idx.count).toBe(2);
expect(idx.hasLive).toBe(true);
});
test('empty in → empty index', () => {
expect(buildLiveIndex(null).count).toBe(0);
expect(buildLiveIndex([]).players).toEqual({});
});
});
describe('attachLiveProgress — the strip join', () => {
const idx = buildLiveIndex(liveResponse);
const out = attachLiveProgress(strips, idx);
test('graded prop with a live box value gets the proto-outcome vs the LOCKED line', () => {
const tb = out[0].props[0];
expect(tb.live).toBeDefined();
expect(tb.live.current).toBe(3);
expect(tb.live.line).toBe(1.5);
expect(tb.live.state).toBe('hit'); // 3 > 1.5, over already cleared
expect(tb.live.progressLabel).toBe('▼8th');
});
test('under uses HOLDS-IF semantics', () => {
const er = out[1].props[1];
expect(er.live.state).toBe('past'); // 3 ER vs U2.5 — line passed, amber
});
test('a stat missing from the box → NO live mark (absent, never 0)', () => {
expect(out[0].props[1].live).toBeUndefined();
});
test('a player not in the box at all → strip untouched (same reference)', () => {
expect(out[2]).toBe(strips[2]);
});
test('settled, dead and awaiting props are never touched', () => {
const settled = [{
player: 'Bryce Harper', team: 'PHI',
props: [
{ stat: 'TB', statType: 'total_bases', line: 1.5, side: 'O', grade: 'A-', outcome: { result: 'hit', actual: 3 } },
{ stat: 'Hits', statType: 'hits', line: 0.5, side: 'O', grade: 'B', dead: true },
{ stat: 'HR', statType: 'home_runs', line: 0.5, side: '', grade: null, awaiting: true },
],
}];
const res = attachLiveProgress(settled, idx);
expect(res[0].props[0].live).toBeUndefined();
expect(res[0].props[1].live).toBeUndefined();
expect(res[0].props[2].live).toBeUndefined();
});
test('empty index → strips returned as-is', () => {
expect(attachLiveProgress(strips, buildLiveIndex(null))).toBe(strips);
expect(attachLiveProgress(strips, null)).toBe(strips);
});
});
describe('gameLiveProximity + sortLiveFirst — the slate float', () => {
const idx = buildLiveIndex(liveResponse);
// Raw odds props as the Slate carries them + the snapshot grade index.
const gradeIndex = {
'bryce harper|total_bases': { grade: 'A-', direction: 'over', line: 1.5, gradedAt: { line: 1.5 } },
'casey mize|strikeouts': { grade: 'B+', direction: 'over', line: 5.5, gradedAt: { line: 5.5 } },
};
test('proximity = current / (floor(line)+1) capped at 1; hit counts 1', () => {
const harperGame = gameLiveProximity([{ player: 'Bryce Harper', stat_type: 'total_bases' }], gradeIndex, idx);
expect(harperGame.tracked).toBe(true);
expect(harperGame.proximity).toBe(1); // 3 TB vs clear-at-2 → capped
const mizeGame = gameLiveProximity([{ player: 'Casey Mize', stat_type: 'strikeouts' }], gradeIndex, idx);
expect(mizeGame.tracked).toBe(true);
expect(mizeGame.proximity).toBeCloseTo(5 / 6, 5);
});
test('ungraded or box-absent props are not tracked', () => {
expect(gameLiveProximity([{ player: 'Kyle Schwarber', stat_type: 'home_runs' }], gradeIndex, idx).tracked).toBe(false);
expect(gameLiveProximity([{ player: 'Bryce Harper', stat_type: 'home_runs' }], gradeIndex, idx).tracked).toBe(false);
});
test('sortLiveFirst floats tracked games by proximity desc, keeps the rest stable', () => {
const games = [
{ id: 'pre-1' },
{ id: 'mize', s: { tracked: true, proximity: 5 / 6 } },
{ id: 'pre-2' },
{ id: 'harper', s: { tracked: true, proximity: 1 } },
];
const sorted = sortLiveFirst(games, (g) => g.s || { tracked: false, proximity: 0 });
expect(sorted.map((g) => g.id)).toEqual(['harper', 'mize', 'pre-1', 'pre-2']);
});
test('no tracked games → original order untouched', () => {
const games = [{ id: 'a' }, { id: 'b' }];
expect(sortLiveFirst(games, () => ({ tracked: false, proximity: 0 })).map((g) => g.id)).toEqual(['a', 'b']);
});
});
+360
View File
@@ -0,0 +1,360 @@
'use strict';
/**
* liveTrackingService (A1 Session 11) — parsers + cache-aside refresh.
*
* Fixtures below are TRIMMED FROM REAL FEEDS captured live on 2026-07-11
* while PHI @ DET (gamePk 824249) was in the bottom of the 8th:
* statsapi.mlb.com/api/v1/schedule?sportId=1&date=2026-07-11&hydrate=linescore
* statsapi.mlb.com/api/v1/game/824249/boxscore
* site.api.espn.com/.../wnba/scoreboard + summary?event=401857057
* Player stat values are the real box numbers from that capture.
*/
const svc = require('../../src/services/liveTrackingService');
const {
parseMlbLiveSchedule,
parseMlbBoxscore,
parseWnbaLiveScoreboard,
parseWnbaBoxscore,
mlbProgress,
fetchLiveTracking,
getLiveTracking,
} = svc;
const { ipToDecimal, LIVE_TTL } = svc.__internals;
// ── MLB fixtures (real shape, captured 2026-07-11) ─────────────────────
const mlbSchedule = {
dates: [{
games: [
{
gamePk: 823357,
status: { abstractGameState: 'Final', detailedState: 'Final' },
teams: { away: { team: { name: 'Milwaukee Brewers' } }, home: { team: { name: 'Pittsburgh Pirates' } } },
},
{
gamePk: 824249,
status: { abstractGameState: 'Live', detailedState: 'In Progress' },
teams: { away: { team: { name: 'Philadelphia Phillies' } }, home: { team: { name: 'Detroit Tigers' } } },
linescore: { currentInning: 8, inningState: 'Bottom', isTopInning: false, scheduledInnings: 9 },
},
{
gamePk: 823276,
status: { abstractGameState: 'Preview', detailedState: 'Pre-Game' },
teams: { away: { team: { name: 'Toronto Blue Jays' } }, home: { team: { name: 'San Diego Padres' } } },
},
],
}],
};
const mlbBoxscore = {
teams: {
home: {
team: { name: 'Detroit Tigers' },
players: {
ID669373: { person: { id: 669373, fullName: 'Tarik Skubal' }, position: { abbreviation: 'P' }, stats: { batting: {}, pitching: {} } }, // not in game — absent
ID123456: {
person: { id: 123456, fullName: 'Hao-Yu Lee' }, position: { abbreviation: '2B' },
stats: { batting: { hits: 2, totalBases: 2, homeRuns: 0, rbi: 0, runs: 0, stolenBases: 0, doubles: 0, baseOnBalls: 0, strikeOuts: 0, atBats: 4 }, pitching: {} },
},
ID663554: {
person: { id: 663554, fullName: 'Casey Mize' }, position: { abbreviation: 'P' },
stats: { batting: {}, pitching: { strikeOuts: 5, earnedRuns: 3, inningsPitched: '5.2', outs: 17, hits: 5, baseOnBalls: 2 } },
},
},
},
away: {
team: { name: 'Philadelphia Phillies' },
players: {
ID547180: {
person: { id: 547180, fullName: 'Bryce Harper' }, position: { abbreviation: '1B' },
stats: { batting: { hits: 2, totalBases: 3, homeRuns: 0, rbi: 0, runs: 0, stolenBases: 0, doubles: 1, baseOnBalls: 0, strikeOuts: 0 }, pitching: {} },
},
ID650911: {
person: { id: 650911, fullName: 'Cristopher Sánchez' }, position: { abbreviation: 'P' },
stats: { batting: {}, pitching: { strikeOuts: 7, earnedRuns: 1, inningsPitched: '7.0', outs: 21, hits: 9, baseOnBalls: 1 } },
},
},
},
},
};
// ── WNBA fixtures (real ESPN shapes, captured 2026-07-11; the scoreboard's
// live event is constructed on the documented shape with status.state 'in'
// since all three games were final at capture time — stated honestly) ────
const wnbaScoreboard = {
events: [
{
id: '401857057',
status: { type: { state: 'in' }, period: 3, displayClock: '4:12' },
competitions: [{
competitors: [
{ homeAway: 'home', team: { displayName: 'Minnesota Lynx' } },
{ homeAway: 'away', team: { displayName: 'New York Liberty' } },
],
}],
},
{
id: '401857059',
status: { type: { state: 'post' }, period: 4 },
competitions: [{ competitors: [] }],
},
],
};
const wnbaSummary = {
boxscore: {
players: [
{
team: { abbreviation: 'NY', displayName: 'New York Liberty' },
statistics: [{
keys: ['minutes', 'points', 'fieldGoalsMade-fieldGoalsAttempted', 'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'freeThrowsMade-freeThrowsAttempted', 'rebounds', 'assists', 'turnovers', 'steals', 'blocks', 'offensiveRebounds', 'defensiveRebounds', 'fouls', 'plusMinus'],
athletes: [
{ athlete: { displayName: 'Breanna Stewart' }, starter: true, didNotPlay: false, stats: ['37', '17', '7-16', '2-3', '1-1', '7', '3', '4', '2', '1', '3', '4', '1', '-4'] },
{ athlete: { displayName: 'Satou Sabally' }, didNotPlay: true, stats: [] }, // DNP — absent
],
}],
},
],
},
};
describe('ipToDecimal — innings in thirds', () => {
test('parses MLB innings notation correctly (5.2 = 5⅔, NOT parseFloat)', () => {
expect(ipToDecimal('5.2')).toBeCloseTo(5 + 2 / 3, 5);
expect(ipToDecimal('7.0')).toBe(7);
expect(ipToDecimal('0.1')).toBeCloseTo(1 / 3, 5);
});
test('null-strict — absent is null, never 0', () => {
expect(ipToDecimal(null)).toBeNull();
expect(ipToDecimal('')).toBeNull();
expect(ipToDecimal('x')).toBeNull();
});
});
describe('parseMlbLiveSchedule', () => {
test('returns ONLY Live games with inning progress', () => {
const live = parseMlbLiveSchedule(mlbSchedule);
expect(live).toHaveLength(1);
expect(live[0].gamePk).toBe(824249);
expect(live[0].home).toBe('Detroit Tigers');
expect(live[0].away).toBe('Philadelphia Phillies');
expect(live[0].progress.label).toBe('▼8th');
expect(live[0].progress.fraction).toBeCloseTo(7.5 / 9, 5);
expect(live[0].progress.half).toBe('bottom');
});
test('empty/malformed schedule → no live games, no throw', () => {
expect(parseMlbLiveSchedule(null)).toEqual([]);
expect(parseMlbLiveSchedule({})).toEqual([]);
});
});
describe('mlbProgress', () => {
test('top of an inning counts the full inning as remaining', () => {
const p = mlbProgress({ currentInning: 4, inningState: 'Top', scheduledInnings: 9 });
expect(p.label).toBe('▲4th');
expect(p.fraction).toBeCloseTo(3 / 9, 5);
});
test('Middle (between halves) counts as the completed top', () => {
const p = mlbProgress({ currentInning: 4, inningState: 'Middle', scheduledInnings: 9 });
expect(p.fraction).toBeCloseTo(3.5 / 9, 5);
});
test('extra innings clamp at 1', () => {
const p = mlbProgress({ currentInning: 11, inningState: 'Bottom', scheduledInnings: 9 });
expect(p.fraction).toBe(1);
});
test('no inning yet → null (absent beats wrong)', () => {
expect(mlbProgress({})).toBeNull();
expect(mlbProgress(null)).toBeNull();
});
test('ordinals — 1st/2nd/3rd/11th', () => {
expect(mlbProgress({ currentInning: 1, inningState: 'Top' }).label).toBe('▲1st');
expect(mlbProgress({ currentInning: 2, inningState: 'Top' }).label).toBe('▲2nd');
expect(mlbProgress({ currentInning: 3, inningState: 'Bottom' }).label).toBe('▼3rd');
expect(mlbProgress({ currentInning: 11, inningState: 'Top' }).label).toBe('▲11th');
});
});
describe('parseMlbBoxscore — real live box values, absent beats wrong', () => {
const players = parseMlbBoxscore(mlbBoxscore);
test('batter values map to VYNDR stat types (real Bryce Harper line)', () => {
const harper = players['bryce harper'];
expect(harper).toBeDefined();
expect(harper.team).toBe('Philadelphia Phillies');
expect(harper.values.hits).toBe(2);
expect(harper.values.total_bases).toBe(3);
expect(harper.values.doubles).toBe(1);
expect(harper.values.home_runs).toBe(0); // he HAS batted — real 0, not fabricated
});
test('pitcher values come from stats.pitching incl. IP in thirds (real Casey Mize line)', () => {
const mize = players['casey mize'];
expect(mize.values.strikeouts).toBe(5);
expect(mize.values.earned_runs).toBe(3);
expect(mize.values.outs).toBe(17);
expect(mize.values.hits_allowed).toBe(5);
expect(mize.values.innings_pitched).toBeCloseTo(5 + 2 / 3, 5);
});
test('a player with EMPTY stats objects has not appeared → absent, never 0', () => {
expect(players['tarik skubal']).toBeUndefined();
});
test('accented names key on the folded nameKey', () => {
expect(players['cristopher sanchez']).toBeDefined();
expect(players['cristopher sanchez'].values.strikeouts).toBe(7);
});
test('malformed input → empty map, no throw', () => {
expect(parseMlbBoxscore(null)).toEqual({});
expect(parseMlbBoxscore({ teams: {} })).toEqual({});
});
});
describe('parseWnbaLiveScoreboard', () => {
test('returns only in-progress events with quarter progress', () => {
const live = parseWnbaLiveScoreboard(wnbaScoreboard);
expect(live).toHaveLength(1);
expect(live[0].id).toBe('401857057');
expect(live[0].home).toBe('Minnesota Lynx');
expect(live[0].progress.label).toBe('Q3');
expect(live[0].progress.fraction).toBeCloseTo(2.5 / 4, 5);
});
test('overtime labels + clamp', () => {
const board = { events: [{ id: '1', status: { type: { state: 'in' }, period: 5 }, competitions: [{ competitors: [] }] }] };
const live = parseWnbaLiveScoreboard(board);
expect(live[0].progress.label).toBe('OT');
expect(live[0].progress.fraction).toBe(1);
});
test('empty board → []', () => {
expect(parseWnbaLiveScoreboard(null)).toEqual([]);
});
});
describe('parseWnbaBoxscore — real ESPN summary shape', () => {
const players = parseWnbaBoxscore(wnbaSummary);
test('maps the keys array onto per-athlete stat rows (real Stewart line)', () => {
const stew = players['breanna stewart'];
expect(stew).toBeDefined();
expect(stew.team).toBe('New York Liberty');
expect(stew.values.points).toBe(17);
expect(stew.values.rebounds).toBe(7);
expect(stew.values.assists).toBe(3);
expect(stew.values.threes).toBe(2); // made, parsed from '2-3'
expect(stew.values.steals).toBe(2);
expect(stew.values.blocks).toBe(1);
expect(stew.values.turnovers).toBe(4);
expect(stew.values.pra).toBe(17 + 7 + 3);
});
test('didNotPlay / empty stats row → absent, never 0', () => {
expect(players['satou sabally']).toBeUndefined();
});
test('malformed input → empty map, no throw', () => {
expect(parseWnbaBoxscore(null)).toEqual({});
expect(parseWnbaBoxscore({ boxscore: {} })).toEqual({});
});
});
describe('fetchLiveTracking — schedule identifies live games, boxscores only for them', () => {
test('MLB: 1 schedule call + 1 boxscore per LIVE game', async () => {
const calls = [];
const fetchJson = jest.fn(async (url) => {
calls.push(url);
if (url.includes('/schedule')) return mlbSchedule;
if (url.includes('/game/824249/boxscore')) return mlbBoxscore;
throw new Error(`unexpected url ${url}`);
});
const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
expect(out.hasLive).toBe(true);
expect(out.games).toHaveLength(1);
expect(out.games[0].id).toBe('824249');
expect(out.games[0].progress.label).toBe('▼8th');
expect(out.games[0].players['bryce harper'].values.total_bases).toBe(3);
// Quota math: exactly 1 schedule + 1 boxscore (one live game).
expect(calls.filter((u) => u.includes('/schedule'))).toHaveLength(1);
expect(calls.filter((u) => u.includes('/boxscore'))).toHaveLength(1);
});
test('MLB: no live games → schedule only, ZERO boxscore calls', async () => {
const fetchJson = jest.fn(async () => ({ dates: [{ games: [{ gamePk: 1, status: { abstractGameState: 'Preview' } }] }] }));
const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
expect(out.hasLive).toBe(false);
expect(out.games).toEqual([]);
expect(fetchJson).toHaveBeenCalledTimes(1);
});
test('WNBA: scoreboard + one summary per live event', async () => {
const fetchJson = jest.fn(async (url) => {
if (url.includes('/scoreboard')) return wnbaScoreboard;
if (url.includes('summary?event=401857057')) return wnbaSummary;
throw new Error(`unexpected url ${url}`);
});
const out = await fetchLiveTracking('wnba', '2026-07-11', { fetchJson });
expect(out.hasLive).toBe(true);
expect(out.games[0].players['breanna stewart'].values.points).toBe(17);
expect(fetchJson).toHaveBeenCalledTimes(2);
});
test('a per-game boxscore failure degrades that game, not the envelope', async () => {
const fetchJson = jest.fn(async (url) => {
if (url.includes('/schedule')) return mlbSchedule;
throw new Error('boom');
});
const out = await fetchLiveTracking('mlb', '2026-07-11', { fetchJson });
expect(out.hasLive).toBe(true);
expect(out.games[0].players).toEqual({});
});
test('unwired sport → honest empty, no fetches', async () => {
const fetchJson = jest.fn();
const out = await fetchLiveTracking('nba', '2026-07-11', { fetchJson });
expect(out).toEqual({ sport: 'nba', date: '2026-07-11', hasLive: false, games: [] });
expect(fetchJson).not.toHaveBeenCalled();
});
});
describe('getLiveTracking — cache-aside, TTL 90s (the POLLING RULE)', () => {
test('cache HIT → no upstream fetch at all', async () => {
const cached = { sport: 'mlb', hasLive: true, games: [{ id: 'x' }] };
const fetchJson = jest.fn();
const cacheGet = jest.fn(async () => cached);
const cacheSet = jest.fn();
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
expect(out).toBe(cached);
expect(fetchJson).not.toHaveBeenCalled();
expect(cacheSet).not.toHaveBeenCalled();
expect(cacheGet).toHaveBeenCalledWith('live:mlb:2026-07-11');
});
test('cache MISS + live games → fetch + write with LIVE_TTL', async () => {
const fetchJson = jest.fn(async (url) => (url.includes('/schedule') ? mlbSchedule : mlbBoxscore));
const cacheGet = jest.fn(async () => null);
const cacheSet = jest.fn();
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
expect(out.hasLive).toBe(true);
expect(out.updated_at).toBeTruthy();
expect(cacheSet).toHaveBeenCalledWith('live:mlb:2026-07-11', expect.objectContaining({ hasLive: true }), LIVE_TTL);
});
test('cache MISS + nothing live → the no-live envelope is ALSO cached (idle polling stays cheap)', async () => {
const fetchJson = jest.fn(async () => ({ dates: [] }));
const cacheGet = jest.fn(async () => null);
const cacheSet = jest.fn();
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
expect(out.hasLive).toBe(false);
expect(cacheSet).toHaveBeenCalledWith('live:mlb:2026-07-11', expect.objectContaining({ hasLive: false }), LIVE_TTL);
});
test('upstream failure → empty envelope, never a throw', async () => {
const fetchJson = jest.fn(async () => { throw new Error('down'); });
const cacheGet = jest.fn(async () => null);
const cacheSet = jest.fn();
const out = await getLiveTracking('mlb', { fetchJson, cacheGet, cacheSet, date: '2026-07-11' });
expect(out.hasLive).toBe(false);
expect(out.error).toBe('unavailable');
});
});
+16 -4
View File
@@ -32,7 +32,8 @@ describe('ROW-GRAMMAR §2 — canonical prop-row slot order (snapshot mode)', ()
'<MovementChip p={p} />', // slot 4b — market movement '<MovementChip p={p} />', // slot 4b — market movement
'p.revisedFrom', // slot 5a — revision strikethrough 'p.revisedFrom', // slot 5a — revision strikethrough
'<GradeBadge grade={p.grade}', // slot 5b — the current grade '<GradeBadge grade={p.grade}', // slot 5b — the current grade
'<OutcomeChip p={p} />', // slot 6 settled result '<LiveTracker p={p} />', // slot 6alive proto-outcome (S11)
'<OutcomeChip p={p} />', // slot 6b — settled result
'<ParlayBtn p={p} />', // slot 7a — action '<ParlayBtn p={p} />', // slot 7a — action
'<BookItTeaser p={p} />', // slot 7b — action '<BookItTeaser p={p} />', // slot 7b — action
'Graded {p.gradedAt.ago}', // slot 8 — provenance, always last 'Graded {p.gradedAt.ago}', // slot 8 — provenance, always last
@@ -60,9 +61,9 @@ describe('ROW-GRAMMAR §2 — canonical prop-row slot order (snapshot mode)', ()
], base); ], base);
}); });
test('settled/dead rows suppress actions (the bet is over)', () => { test('live/settled/dead rows suppress actions (the bet window is over) — S11 amendment', () => {
expect(strip).toContain('{!p.outcome && !p.dead && <ParlayBtn p={p} />}'); expect(strip).toContain('{!p.outcome && !p.dead && !p.live && <ParlayBtn p={p} />}');
expect(strip).toContain('{!p.outcome && !p.dead && <BookItTeaser p={p} />}'); expect(strip).toContain('{!p.outcome && !p.dead && !p.live && <BookItTeaser p={p} />}');
}); });
}); });
@@ -94,6 +95,17 @@ describe('ROW-GRAMMAR §3 — one meaning per color', () => {
expect(src).toContain("steam ? 'var(--amber"); expect(src).toContain("steam ? 'var(--amber");
expect(src).not.toContain('--miss'); expect(src).not.toContain('--miss');
}); });
test('live TRACKING mark is green/amber only — an in-progress prop is never red (S11)', () => {
const src = section('export function LiveTracker', 'export function DotStrip');
expect(src).toContain('var(--g-a');
expect(src).toContain('var(--amber');
expect(src).not.toContain('--miss');
// The label never implies re-grading: TRACKING, read locked pre-game.
expect(src).toContain('TRACKING — read locked pre-game');
// Data → mono.
expect(src).toContain('className="mono"');
});
}); });
describe('ROW-GRAMMAR §1.4 — no truncation of names/times/pitchers', () => { describe('ROW-GRAMMAR §1.4 — no truncation of names/times/pitchers', () => {
+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 type { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, Tier } from '@/components/PropRow'; import { PropRowProp, Tier } from '@/components/PropRow';
import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter'; 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 { emptyStateCopy } from '@/lib/emptyState';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the // 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 PitcherGame { home?: PitcherSide; away?: PitcherSide }
interface PitcherResponse { games?: PitcherGame[] } 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 // Session 45 — map a merged SlateGame + the pre-graded snapshot indices into the
// VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas). // VYNDR 2.0 GameCardData (player name once, archetype, locked grades + deltas).
type GradeIndex = ReturnType<typeof indexGrades>; type GradeIndex = ReturnType<typeof indexGrades>;
type DeltaIndex = ReturnType<typeof indexDeltas>; type DeltaIndex = ReturnType<typeof indexDeltas>;
type PitcherMap = ReturnType<typeof buildPitcherMap>; 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 // Session 60 (night2/C) — ONE stat selection filters every layer: props on
// the cards narrow together with the streaks + hot-list panels below. // the cards narrow together with the streaks + hot-list panels below.
const props = statFilter && statFilter !== 'all' const props = statFilter && statFilter !== 'all'
? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter) ? g.props.filter((p) => String(p.stat_type || '').toLowerCase() === statFilter)
: g.props; : 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 { return {
id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`, id: `${g.sport}-${g.awayAbbr || g.awayTeam}-${g.homeAbbr || g.homeTeam}`,
sport: g.sport, sport: g.sport,
@@ -192,9 +209,9 @@ function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: D
time: formatGameTime(g.gameTime), time: formatGameTime(g.gameTime),
venue: g.venue, venue: g.venue,
lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [], lines: g.gameLines?.books ? detectBestLines(g.gameLines.books) : [],
// Session 59 (work-order 1.6) — pass the game's participants so the join // Session 59 (work-order 1.6) — the join guard drops bad feed rows inside
// guard can drop bad feed rows (a player whose real team isn't in this game). // buildPlayerStripsFromProps (game participants passed above).
playerStrips: buildPlayerStripsFromProps(props, gradeIndex, deltaIndex, Date.now(), { home: g.homeTeam, away: g.awayTeam }, viability), playerStrips: liveStrips,
// Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers). // Session 46 — MLB starting pitchers (statsapi.mlb.com via /pitchers).
pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined, pitchers: g.sport === 'mlb' ? pitchersForGameTeams(g.awayTeam, g.homeTeam, pitcherMap) : undefined,
streaks: (g.streaks || []).map((s) => ({ player: s.player, text: s.description || '' })), 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[]>([]); const [pitcherGames, setPitcherGames] = useState<PitcherGame[]>([]);
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). // 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); 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), // Session 64 (A1-S5) — date navigation: -1 = Yesterday (results surface),
// 0 = Today, +1 = Tomorrow (schedule until lines post). // 0 = Today, +1 = Tomorrow (schedule until lines post).
const [dateOffset, setDateOffset] = useState(0); const [dateOffset, setDateOffset] = useState(0);
@@ -606,6 +626,47 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
return () => clearInterval(id); 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 // Session 24 — switching sport resets the stat filter. The categories
// differ per sport (Points vs Hits), so a stale "points" filter would // differ per sport (Points vs Hits), so a stale "points" filter would
// silently blank the MLB panels. Always land back on 'all'. // 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 gradeIndex = useMemo(() => indexGrades(snapGrades), [snapGrades]);
const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]); const deltaIndex = useMemo(() => indexDeltas(snapDeltas), [snapDeltas]);
const pitcherMap = useMemo(() => buildPitcherMap(pitcherGames), [pitcherGames]); 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(() => { const filteredGames = useMemo(() => {
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never // 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); .filter((g): g is SlateGame => g !== null);
}, [games, searchQuery]); }, [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 // 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 // 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 // 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)} />} {dateOffset === -1 && <YesterdaySettle date={etDateWithOffset(-1)} />}
<div style={{ display: 'grid', gap: 16 }}> <div style={{ display: 'grid', gap: 16 }}>
{filteredGames.map((g, i) => ( {orderedGames.map((g, i) => (
<VyndrGameCard <VyndrGameCard
key={`${g.sport}-${g.homeTeam}-${g.awayTeam}-${i}`} 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} preferredBooks={preferredBooks}
onOpen={() => router.push('/scan')} 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 [linesExpanded, setLinesExpanded] = useState(false);
const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]); const collapsed = useMemo(() => collapseStrips(g.playerStrips || []), [g.playerStrips]);
const stripsToRender = showAllReads ? collapsed.sorted : collapsed.visible; 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 bestLine = (pick: (ln: GameLine) => boolean, val: (ln: GameLine) => string) => {
const ln = (g.lines || []).find(pick); const ln = (g.lines || []).find(pick);
return ln ? val(ln) : (g.lines && g.lines[0] ? val(g.lines[0]) : '—'); 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 archetype + horizontal stats + all graded props on one line); fall
back to the legacy per-prop rows for callers that don't supply strips. */} back to the legacy per-prop rows for callers that don't supply strips. */}
<div style={{ padding: '13px 16px' }}> <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 ? ( {g.playerStrips && g.playerStrips.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{stripsToRender.map((ps, i) => ( {stripsToRender.map((ps, i) => (
+70 -2
View File
@@ -38,6 +38,66 @@ export interface StripProp {
// (true = cleared). Both absent → nothing renders. // (true = cleared). Both absent → nothing renders.
history?: Array<{ t: string; line: number }> | null; history?: Array<{ t: string; line: number }> | null;
last10Dots?: boolean[] | 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 /** 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" /> <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} /> <OutcomeChip p={p} />
{!p.outcome && !p.dead && <ParlayBtn p={p} />} {/* ROW-GRAMMAR slot 7 — actions are suppressed once the
{!p.outcome && !p.dead && <BookItTeaser p={p} />} 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 && ( {p.gradedAt?.ago && (
<span style={{ color: 'var(--text-2)' }}> <span style={{ color: 'var(--text-2)' }}>
Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''} 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}`]; const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`];
byPlayer[pk].props.push({ byPlayer[pk].props.push({
stat: statShort(rec.stat_type || rec.stat), 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, line: rec.line,
side, side,
grade: rec.grade, grade: rec.grade,
@@ -391,7 +394,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
}); });
} else { } else {
byPlayer[pk].props.push({ 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, book: p.book || null,
bestBook: detectBestBook(p.books, p.direction || 'over', p.line), bestBook: detectBestBook(p.books, p.direction || 'over', p.line),
}); });