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