diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 2c095f2..1cbe306 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -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`). diff --git a/CLAUDE.md b/CLAUDE.md index b39ce1d..4c11781 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/specs/LIVE-TRACKING.md b/specs/LIVE-TRACKING.md new file mode 100644 index 0000000..e609a37 --- /dev/null +++ b/specs/LIVE-TRACKING.md @@ -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 — diff --git a/specs/ROW-GRAMMAR.md b/specs/ROW-GRAMMAR.md index 3b3ee89..6c43f7c 100644 --- a/specs/ROW-GRAMMAR.md +++ b/specs/ROW-GRAMMAR.md @@ -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. diff --git a/src/app.js b/src/app.js index ff5d88f..a56485c 100644 --- a/src/app.js +++ b/src/app.js @@ -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')); diff --git a/src/routes/live.js b/src/routes/live.js new file mode 100644 index 0000000..92912e4 --- /dev/null +++ b/src/routes/live.js @@ -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; diff --git a/src/services/liveTrackingService.js b/src/services/liveTrackingService.js new file mode 100644 index 0000000..352a078 --- /dev/null +++ b/src/services/liveTrackingService.js @@ -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 }, +}; diff --git a/tests/integration/liveRoute.test.js b/tests/integration/liveRoute.test.js new file mode 100644 index 0000000..1f00f88 --- /dev/null +++ b/tests/integration/liveRoute.test.js @@ -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'); + }); +}); diff --git a/tests/unit/liveProgress.test.js b/tests/unit/liveProgress.test.js new file mode 100644 index 0000000..c9d5c72 --- /dev/null +++ b/tests/unit/liveProgress.test.js @@ -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']); + }); +}); diff --git a/tests/unit/liveTrackingService.test.js b/tests/unit/liveTrackingService.test.js new file mode 100644 index 0000000..ce336f2 --- /dev/null +++ b/tests/unit/liveTrackingService.test.js @@ -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'); + }); +}); diff --git a/tests/unit/rowGrammar.test.js b/tests/unit/rowGrammar.test.js index 8b30e55..c7d56c9 100644 --- a/tests/unit/rowGrammar.test.js +++ b/tests/unit/rowGrammar.test.js @@ -32,7 +32,8 @@ describe('ROW-GRAMMAR §2 — canonical prop-row slot order (snapshot mode)', () '', // slot 4b — market movement 'p.revisedFrom', // slot 5a — revision strikethrough '', // slot 6 — settled result + '', // slot 6a — live proto-outcome (S11) + '', // slot 6b — settled result '', // slot 7a — action '', // 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 && }'); - expect(strip).toContain('{!p.outcome && !p.dead && }'); + test('live/settled/dead rows suppress actions (the bet window is over) — S11 amendment', () => { + expect(strip).toContain('{!p.outcome && !p.dead && !p.live && }'); + expect(strip).toContain('{!p.outcome && !p.dead && !p.live && }'); }); }); @@ -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', () => { diff --git a/web/public/sw.js b/web/public/sw.js index 1c17784..01366ab 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s,r,i={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"u">typeof registration?registration.scope:""},n=e=>[i.prefix,e,i.suffix].filter(e=>e&&e.length>0).join("-"),c=e=>e||n(i.precache),o=e=>e||n(i.runtime);var l=class extends Error{details;constructor(e,t){super(((e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a})(e,t)),this.name=e,this.details=t}};function h(e){return new Promise(t=>setTimeout(t,e))}let u=new Set;function d(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function f(e,t,a,s){let r=d(t.url,a);if(t.url===r)return e.match(t,s);let i={...s,ignoreSearch:!0};for(let n of(await e.keys(t,i)))if(r===d(n.url,a))return e.match(n,s)}var p=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let m=async()=>{for(let e of u)await e()},w="-precache-",g=async(e,t=w)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},y=(e,t)=>{let a=t();return e.waitUntil(a),a},_=(e,t)=>t.some(t=>e instanceof t),b=new WeakMap,v=new WeakMap,R=new WeakMap,E={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return b.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return q(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function q(e){if(e instanceof IDBRequest){let t;return t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",i)},r=()=>{t(q(e.result)),s()},i=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",i)}),R.set(t,e),t}if(v.has(e))return v.get(e);let t=function(e){if("function"==typeof e)return(r||(r=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(x(this),t),q(this.request)}:function(...t){return q(e.apply(x(this),t))};return(e instanceof IDBTransaction&&function(e){if(b.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",i),e.removeEventListener("abort",i)},r=()=>{t(),s()},i=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",i),e.addEventListener("abort",i)});b.set(e,t)}(e),_(e,s||(s=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,E):e}(e);return t!==e&&(v.set(e,t),R.set(t,e)),t}let x=e=>R.get(e);function D(e,t,{blocked:a,upgrade:s,blocking:r,terminated:i}={}){let n=indexedDB.open(e,t),c=q(n);return s&&n.addEventListener("upgradeneeded",e=>{s(q(n.result),e.oldVersion,e.newVersion,q(n.transaction),e)}),a&&n.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{i&&e.addEventListener("close",()=>i()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let S=["get","getKey","getAll","getAllKeys","count"],k=["put","add","delete","clear"],T=new Map;function P(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(T.get(t))return T.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=k.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||S.includes(a)))return;let i=async function(e,...t){let i=this.transaction(e,r?"readwrite":"readonly"),n=i.store;return s&&(n=n.index(t.shift())),(await Promise.all([n[a](...t),r&&i.done]))[0]};return T.set(t,i),i}E={...e=E,get:(t,a,s)=>P(t,a)||e.get(t,a,s),has:(t,a)=>!!P(t,a)||e.has(t,a)};let C=["continue","continuePrimaryKey","advance"],N={},I=new WeakMap,U=new WeakMap,L={get(e,t){if(!C.includes(t))return e[t];let a=N[t];return a||(a=N[t]=function(...e){I.set(this,U.get(this)[t](...e))}),a}};async function*A(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,L);for(U.set(a,t),R.set(a,x(t));t;)yield a,t=await (I.get(a)||t.continue()),I.delete(a)}function O(e,t){return t===Symbol.asyncIterator&&_(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&_(e,[IDBIndex,IDBObjectStore])}E={...t=E,get:(e,a,s)=>O(e,a)?A:t.get(e,a,s),has:(e,a)=>O(e,a)||t.has(e,a)};let M=async(e,t)=>{let s=null;if(e.url&&(s=new URL(e.url).origin),s!==self.location.origin)throw new l("cross-origin-copy-response",{origin:s});let r=e.clone(),i={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},n=t?t(i):i,c=!function(){if(void 0===a){let e=new Response("");if("body"in e)try{new Response(e.body),a=!0}catch{a=!1}a=!1}return a}()?await r.blob():r.body;return new Response(c,n)},B="requests",K="queueName";var F=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(B,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(B).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(B,K,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(B,K,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(B,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){return(await (await this.getDb()).transaction(B).store.index(K).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await D("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(B)&&e.deleteObjectStore(B),e.createObjectStore(B,{autoIncrement:!0,keyPath:"id"}).createIndex(K,K,{unique:!1})}},W=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new F}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}};let j=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];var H=class e{_requestData;static async fromRequest(t){let a={url:t.url,headers:{}};for(let e of("GET"!==t.method&&(a.body=await t.clone().arrayBuffer()),t.headers.forEach((e,t)=>{a.headers[t]=e}),j))void 0!==t[e]&&(a[e]=t[e]);return new e(a)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new e(this.toObject())}};let $="serwist-background-sync",V=new Set,Q=e=>{let t={request:new H(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var G=class{_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:s}={}){if(V.has(e))throw new l("duplicate-queue-name",{name:e});V.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=s||10080,this._forceSyncFallback=!!t,this._queueStore=new W(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let s of e){let e=60*this._maxRetentionTime*1e3;t-s.timestamp>e?await this._queueStore.deleteEntry(s.id):a.push(Q(s))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},s){let r={requestData:(await H.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(r.metadata=t),s){case"push":await this._queueStore.pushEntry(r);break;case"unshift":await this._queueStore.unshiftEntry(r)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let s=60*this._maxRetentionTime*1e3;return a-t.timestamp>s?this._removeRequest(e):Q(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new l("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${$}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${$}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return V}},z=class{_queue;constructor(e,t){this._queue=new G(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}};let Y={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function J(e){return"string"==typeof e?new Request(e):e}var X=class{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(const a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new p,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=J(e),s=await this.getPreloadResponse();if(s)return s;let r=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let i=a.clone();try{let e;for(let s of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await s({event:t,request:i,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:i.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=J(e),{cacheName:s,matchOptions:r}=this._strategy,i=await this.getCacheKey(a,"read"),n={...r,cacheName:s};for(let e of(t=await caches.match(i,n),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:i,event:this.event})||void 0;return t}async cachePut(e,t){let a=J(e);await h(0);let s=await this.getCacheKey(a,"write");if(!t)throw new l("cache-put-with-no-response",{url:new URL(String(s.url),location.href).href.replace(RegExp(`^${location.origin}`),"")});let r=await this._ensureResponseSafeToCache(t);if(!r)return!1;let{cacheName:i,matchOptions:n}=this._strategy,c=await self.caches.open(i),o=this.hasCallback("cacheDidUpdate"),u=o?await f(c,s.clone(),["__WB_REVISION__"],n):null;try{await c.put(s,o?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await m(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:i,oldResponse:u,newResponse:r.clone(),request:s,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let s=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))s=J(await e({mode:t,request:s,event:this.event,params:this.params}));this._cacheKeys[a]=s}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),s=s=>{let r={...s,state:a};return t[e](r)};yield s}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){return}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}},Z=class{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=o(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,s=new X(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),r=this._getResponse(s,a,t);return[r,this._awaitComplete(r,s,a,t)]}async _getResponse(e,t,a){let s;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(s=await this._handle(t,e),void 0===s||"error"===s.type)throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(let i of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await i({error:r,event:a,request:t})))break}if(!s)throw r}for(let r of e.iterateCallbacks("handlerWillRespond"))s=await r({event:a,request:t,response:s});return s}async _awaitComplete(e,t,a,s){let r,i;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(i=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:i}),t.destroy(),i)throw i}},ee=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s=[],r=[];if(this._networkTimeoutSeconds){let{id:i,promise:n}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=i,r.push(n)}let i=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(i);let n=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await i)());if(!n)throw new l("no-response",{url:e.url});return n}_getTimeoutPromise({request:e,logs:t,handler:a}){let s;return{promise:new Promise(t=>{s=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:s}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:s}){let r,i;try{i=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!i)&&(i=await s.cacheMatch(t)),i}},et=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=h(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(s=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}};let ea=e=>e&&"object"==typeof e?e:{handle:e};var es=class{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=ea(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=ea(e)}},er=class e extends Z{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await M(e):e};constructor(t={}){t.cacheName=c(t.cacheName),super(t),this._fallbackToNetwork=!1!==t.fallbackToNetwork,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let s=await t.cacheMatch(e);return s||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,s=t.params||{};if(this._fallbackToNetwork){let r=s.integrity,i=e.integrity,n=!i||i===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?i||r:void 0})),r&&n&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new l("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let t=null,a=0;for(let[s,r]of this.plugins.entries())r!==e.copyRedirectedCacheableResponsesPlugin&&(r===e.defaultPrecacheCacheabilityPlugin&&(t=s),r.cacheWillUpdate&&a++);0===a?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):a>1&&null!==t&&this.plugins.splice(t,1)}},ei=class extends es{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}},en=class extends es{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}};let ec=e=>{if(!e)throw new l("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let s=new URL(a,location.href),r=new URL(a,location.href);return s.searchParams.set("__WB_REVISION__",t),{cacheKey:s.href,url:r.href}};var eo=class{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}};let el=async(e,t,a)=>{let s=t.map((e,t)=>({index:t,item:e})),r=async e=>{let t=[];for(;;){let r=s.pop();if(!r)return e(t);let i=await a(r.item);t.push({result:i,index:r.index})}},i=Array.from({length:e},()=>new Promise(r));return(await Promise.all(i)).flat().sort((e,t)=>e.indexe.result)};"u">typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent);let eh="cache-entries",eu=e=>{let t=new URL(e,location.href);return t.hash="",t.href};var ed=class{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eu(e)}`}_upgradeDb(e){let t=e.createObjectStore(eh,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&function(e,{blocked:t}={}){let a=indexedDB.deleteDatabase(e);t&&a.addEventListener("blocked",e=>t(e.oldVersion,e)),q(a).then(()=>void 0)}(this._cacheName)}async setTimestamp(e,t){e=eu(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},s=(await this.getDb()).transaction(eh,"readwrite",{durability:"relaxed"});await s.store.put(a),await s.done}async getTimestamp(e){return(await (await this.getDb()).get(eh,this._getId(e)))?.timestamp}async expireEntries(e,t){let a=await (await this.getDb()).transaction(eh,"readwrite").store.index("timestamp").openCursor(null,"prev"),s=[],r=0;for(;a;){let i=a.value;i.cacheName===this._cacheName&&(e&&i.timestamp=t?(a.delete(),s.push(i.url)):r++),a=await a.continue()}return s}async getDb(){return this._db||(this._db=await D("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}},ef=class{_isRunning=!1;_rerunRequested=!1;_maxEntries;_maxAgeSeconds;_matchOptions;_cacheName;_timestampModel;constructor(e,t={}){this._maxEntries=t.maxEntries,this._maxAgeSeconds=t.maxAgeSeconds,this._matchOptions=t.matchOptions,this._cacheName=e,this._timestampModel=new ed(e)}async expireEntries(){if(this._isRunning){this._rerunRequested=!0;return}this._isRunning=!0;let e=this._maxAgeSeconds?Date.now()-1e3*this._maxAgeSeconds:0,t=await this._timestampModel.expireEntries(e,this._maxEntries),a=await self.caches.open(this._cacheName);for(let e of t)await a.delete(e,this._matchOptions);this._isRunning=!1,this._rerunRequested&&(this._rerunRequested=!1,this.expireEntries())}async updateTimestamp(e){await this._timestampModel.setTimestamp(e,Date.now())}async isURLExpired(e){if(!this._maxAgeSeconds)return!1;let t=await this._timestampModel.getTimestamp(e),a=Date.now()-1e3*this._maxAgeSeconds;return void 0===t||t{u.add(e)})(()=>this.deleteCacheAndMetadata())}_getCacheExpiration(e){if(e===o())throw new l("expire-custom-caches-only");let t=this._cacheExpirations.get(e);return t||(t=new ef(e,this._config),this._cacheExpirations.set(e,t)),t}cachedResponseWillBeUsed({event:e,cacheName:t,request:a,cachedResponse:s}){if(!s)return null;let r=this._isResponseDateFresh(s),i=this._getCacheExpiration(t),n="last-used"===this._config.maxAgeFrom,c=(async()=>{n&&await i.updateTimestamp(a.url),await i.expireEntries()})();try{e.waitUntil(c)}catch{}return r?s:null}_isResponseDateFresh(e){if("last-used"===this._config.maxAgeFrom)return!0;let t=Date.now();if(!this._config.maxAgeSeconds)return!0;let a=this._getDateHeaderTimestamp(e);return null===a||a>=t-1e3*this._config.maxAgeSeconds}_getDateHeaderTimestamp(e){if(!e.headers.has("date"))return null;let t=new Date(e.headers.get("date")).getTime();return Number.isNaN(t)?null:t}async cacheDidUpdate({cacheName:e,request:t}){let a=this._getCacheExpiration(e);await a.updateTimestamp(t.url),await a.expireEntries()}async deleteCacheAndMetadata(){for(let[e,t]of this._cacheExpirations)await self.caches.delete(e),await t.delete();this._cacheExpirations=new Map}};let em=/^\/(\w+\/)?collect/,ew=({serwist:e,cacheName:t,...a})=>{let s,r,c=t||n(i.googleAnalytics),o=new z("serwist-google-analytics",{maxRetentionTime:2880,onSync:async({queue:e})=>{let t;for(;t=await e.shiftRequest();){let{request:s,timestamp:r}=t,i=new URL(s.url);try{let e="POST"===s.method?new URLSearchParams(await s.clone().text()):i.searchParams,t=r-(Number(e.get("qt"))||0),n=Date.now()-t;if(e.set("qt",String(n)),a.parameterOverrides)for(let t of Object.keys(a.parameterOverrides)){let s=a.parameterOverrides[t];e.set(t,s)}"function"==typeof a.hitFilter&&a.hitFilter.call(null,e),await fetch(new Request(i.origin+i.pathname,{body:e.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(a){throw await e.unshiftRequest(t),a}}}});for(let t of[new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,new ee({cacheName:c}),"GET"),new es(s=({url:e})=>"www.google-analytics.com"===e.hostname&&em.test(e.pathname),r=new et({plugins:[o]}),"GET"),new es(s,r,"POST")])e.registerRoute(t)};var eg=class{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}},ey=class extends Z{async _handle(e,t){let a,s=await t.cacheMatch(e);if(s);else try{s=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}},e_=class extends es{constructor(e,t){super(({request:a})=>{let s=e.getUrlsToPrecacheKeys();for(let r of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:s=!0,urlManipulation:r}={}){let i=new URL(e,location.href);i.hash="",yield i.href;let n=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(i,a);if(yield n.href,t&&n.pathname.endsWith("/")){let e=new URL(n.href);e.pathname+=t,yield e.href}if(s){let e=new URL(n.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:i}))yield e.href}(a.url,t)){let t=s.get(r);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eb=class{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}},ev=class{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:s,navigationPreload:r=!1,cacheId:n,clientsClaim:o=!1,runtimeCaching:l,offlineAnalyticsConfig:h,disableDevLogs:u=!1,fallbacks:d,requestRules:f}={}){const{precacheStrategyOptions:p,precacheRouteOptions:m,precacheMiscOptions:w}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:i,fallbackToNetwork:n,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:f=10,navigateFallback:p,navigateFallbackAllowlist:m,navigateFallbackDenylist:w}=t??{};return{precacheStrategyOptions:{cacheName:c(a),plugins:[...s,new eb({precacheController:e})],fetchOptions:r,matchOptions:i,fallbackToNetwork:n},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:f,navigateFallback:p,navigateFallbackAllowlist:m,navigateFallbackDenylist:w}}})(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new er(p),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=f,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),s&&s.length>0&&self.importScripts(...s),r&&self.registration?.navigationPreload&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{}))}),void 0!==n&&(e=>{var t=e;for(let e of Object.keys(i))(e=>{let a=t[e];"string"==typeof a&&(i[e]=a)})(e)})({prefix:n}),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),o&&self.addEventListener("activate",()=>self.clients.claim()),e&&e.length>0&&this.addToPrecacheList(e),w.cleanupOutdatedCaches&&(e=>{self.addEventListener("activate",t=>{t.waitUntil(g(c(e)).then(e=>{}))})})(p.cacheName),this.registerRoute(new e_(this,m)),w.navigateFallback&&this.registerRoute(new ei(this.createHandlerBoundToUrl(w.navigateFallback),{allowlist:w.navigateFallbackAllowlist,denylist:w.navigateFallbackDenylist})),void 0!==h&&("boolean"==typeof h?h&&ew({serwist:this}):ew({...h,serwist:this})),void 0!==l){if(void 0!==d){const e=new eg({fallbackUrls:d.entries,serwist:this});l.forEach(t=>{t.handler instanceof Z&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(const e of l)this.registerCapture(e.matcher,e.handler,e.method)}u&&(self.__WB_DISABLE_DEV_LOGS=!0)}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:s}=ec(a),r="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(s)&&this._urlsToCacheKeys.get(s)!==e)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(s),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:s});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(s,e),this._urlsToCacheModes.set(s,r)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new en(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let i,n=r.match({url:e,sameOrigin:t,request:a,event:s});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:r,params:i}}return{}}};let eR="/offline",eE=["pages","api-responses","next-static","static-media","fallback","offline-fallback"],eq=[{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/api/"),handler:new ee({cacheName:"api-responses",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:100,maxAgeSeconds:3600})]})},{matcher:({request:e})=>"navigate"===e.mode,handler:new ee({cacheName:"pages",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400}),{handlerDidError:async()=>await caches.match(eR)||Response.error()}]})},{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/_next/static/"),handler:new ey({cacheName:"next-static",plugins:[new ep({maxEntries:200,maxAgeSeconds:2592e3})]})},{matcher:({url:e})=>e.pathname.startsWith("/images/")||e.pathname.startsWith("/icons/")||/\.(?:png|jpe?g|gif|svg|webp|ico|woff2?)$/.test(e.pathname),handler:new ey({cacheName:"static-media",plugins:[new ep({maxEntries:100,maxAgeSeconds:2592e3})]})},{matcher:()=>!0,handler:new ee({cacheName:"fallback",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400})]})}];new ev({precacheEntries:[{'revision':'77df4417a679028a5d1c7e2cdf21b2be','url':'/_next/static/Svdsmb6FQN9Zi6U6Z_GV9/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/Svdsmb6FQN9Zi6U6Z_GV9/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1079-4eabb0cad585bc18.js'},{'revision':null,'url':'/_next/static/chunks/1098-7bedeeccda9aece6.js'},{'revision':null,'url':'/_next/static/chunks/1896-4989a39d80811e5e.js'},{'revision':null,'url':'/_next/static/chunks/1942-bc0755f4a7d5b4a6.js'},{'revision':null,'url':'/_next/static/chunks/1958-56cbbb8dba15fd2f.js'},{'revision':null,'url':'/_next/static/chunks/2346-36e36720a5f6591a.js'},{'revision':null,'url':'/_next/static/chunks/4180-289c7e54afc32311.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-e356ca5ba0218e27.js'},{'revision':null,'url':'/_next/static/chunks/52774a7f.f2ab7a9b4b8ba576.js'},{'revision':null,'url':'/_next/static/chunks/5838-7e0aa455e9f24259.js'},{'revision':null,'url':'/_next/static/chunks/7602.2868ff821d53ee09.js'},{'revision':null,'url':'/_next/static/chunks/8388-dcc9375b89010049.js'},{'revision':null,'url':'/_next/static/chunks/8500-f62a38ff68ab7f42.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-9623f3245f088d02.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/about/page-d71c2e7fa60ae979.js'},{'revision':null,'url':'/_next/static/chunks/app/account/page-73f3b2070394b584.js'},{'revision':null,'url':'/_next/static/chunks/app/admin/page-37cc78f8c5ff5f0b.js'},{'revision':null,'url':'/_next/static/chunks/app/api/accuracy/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/admin/stats/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5B...path%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/content/%5B...path%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/desk/pack/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/gamelines/%5Bsport%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hero-prop/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hotlist/%5Bsport%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/mine/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/model/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/lines/%5B...path%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/newsletter/subscribe/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/calculate/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/preferences/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/profiles/%5Bhandle%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/profiles/me/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/pitchers/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/snapshot/%5Bsport%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/snapshot/summary/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/cascade/%5Bplayer%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/depth/%5Bteam%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/leaders/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/lineup/%5Bteam%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/player/%5Bname%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/streaks/%5Bsport%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/team/%5Babbr%5D/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ticker/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scan-meter/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-a2c15c101f636b81.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/compare/page-f2d43b7780d1bd31.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-ab0388fc72f01a2b.js'},{'revision':null,'url':'/_next/static/chunks/app/desk/page-4e4a17bc92323461.js'},{'revision':null,'url':'/_next/static/chunks/app/explore/page-61a50b6020fdea5a.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-daec5a9eca2533f8.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-e7931e081ed05554.js'},{'revision':null,'url':'/_next/static/chunks/app/help/page-9a30b826fa0bac2c.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-91e2a44c2ed6e42c.js'},{'revision':null,'url':'/_next/static/chunks/app/invite/page-dd722848f9b11a8f.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-d53aa89d3477b22f.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-7f5ca26eaa6fa0ce.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-1353dedc37818465.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-a033ff766b054d78.js'},{'revision':null,'url':'/_next/static/chunks/app/methodology/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-50232f7747be762d.js'},{'revision':null,'url':'/_next/static/chunks/app/notifications/page-d71c2e7fa60ae979.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-5b1314e64bd5b7e8.js'},{'revision':null,'url':'/_next/static/chunks/app/onboarding/page-25016befabb18d70.js'},{'revision':null,'url':'/_next/static/chunks/app/opengraph-image/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/page-40a0d1e7944c253a.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/layout-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/opengraph-image/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/page-37eb3a826c9ff4ee.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-1d2ec3a54fa294ba.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-6721366003ffc04e.js'},{'revision':null,'url':'/_next/static/chunks/app/report/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-8a1f4ccd8888ecf6.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/page-d8293dbd139e721b.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-e8a5ff0fd5d15c55.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-1d70d79337ba0a87.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-7504f5a7f545e561.js'},{'revision':null,'url':'/_next/static/chunks/app/team/%5Babbr%5D/page-930ab2b3711d1467.js'},{'revision':null,'url':'/_next/static/chunks/app/terminal/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-9c9d018594b413aa.js'},{'revision':null,'url':'/_next/static/chunks/app/twitter-image/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/u/%5Bhandle%5D/opengraph-image/route-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/app/u/%5Bhandle%5D/page-c54b4e7169ddc8ab.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-d687298827d08cbb.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-ad9f00a3b07988bd.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-84b715ee6d2e0fcb.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-f3cf5026d8b3f599.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-c012f52556ce390d.js'},{'revision':null,'url':'/_next/static/chunks/framework-95da69ac6843d788.js'},{'revision':null,'url':'/_next/static/chunks/main-a354262253293123.js'},{'revision':null,'url':'/_next/static/chunks/main-app-fa1041f45e830136.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-131165429e61dc72.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-2931ac77f014a0eb.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-131165429e61dc72.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-42b9616650231776.js'},{'revision':null,'url':'/_next/static/css/6c56eebe3e12b49b.css'},{'revision':'cd34c7793d9776838308cfbe5d93922b','url':'/_next/static/media/011e180705008d6f-s.woff2'},{'revision':'324703f03c390d2e2a4f387de85fe63d','url':'/_next/static/media/0aa834ed78bf6d07-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'7de6d31e5076eb437418b6e01998ee12','url':'/_next/static/media/20535187d867b7b9-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'0d11e07b5aaafda6ae2efc53256932ec','url':'/_next/static/media/37786be940ec402b-s.p.woff2'},{'revision':'5de0bff80f9f432b5cf52c1c2f80fe20','url':'/_next/static/media/46e154b2fcbd6033-s.woff2'},{'revision':'6041b3192dc3e1a50141d5d418a24199','url':'/_next/static/media/5356a6a4f2c8c8d8-s.woff2'},{'revision':'f572f7b57d27ec7fd8373961f0b762b0','url':'/_next/static/media/58f386aa6b1a2a92-s.woff2'},{'revision':'e829cbe042e8b2b2e6b99c0eec9e230d','url':'/_next/static/media/656feb427634a431-s.woff2'},{'revision':'54f02056e07c55023315568c637e3a96','url':'/_next/static/media/67957d42bae0796d-s.woff2'},{'revision':'e220ad1849c282590e47db0ff88fc3f3','url':'/_next/static/media/704b853f32d191d5-s.woff2'},{'revision':'662cde3543f3375874e26c26845cec22','url':'/_next/static/media/73cb51aac9c97f90-s.woff2'},{'revision':'afac1fab355fe8db5c2dcaa7623dffb3','url':'/_next/static/media/7ba5fb2a8c88521c-s.woff2'},{'revision':'c94e6e6c23e789fcb0fc60d790c9d2c1','url':'/_next/static/media/886030b0b59bc5a7-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'17a5b2a15bf5647b49cadb9a3bb02e1c','url':'/_next/static/media/92eeb95d069020cc-s.woff2'},{'revision':'4a4e74bed5809194e4bc6538eb1a1e30','url':'/_next/static/media/939c4f875ee75fbb-s.woff2'},{'revision':'6708425c446b05de1c2d471a0eaf4099','url':'/_next/static/media/98e207f02528a563-s.p.woff2'},{'revision':'13dface50f6dc0bff1015561c84119e6','url':'/_next/static/media/991629005c80bdf1-s.woff2'},{'revision':'216ee1a35b060106ed2852cdd8c026c7','url':'/_next/static/media/99dcf268bda04fe5-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'782150e6836b9b074d1a798807adcb18','url':'/_next/static/media/bb3ef058b751a6ad-s.p.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'75fa2e527eaae805bbf44e3378d1232c','url':'/_next/static/media/d26bbd13d6b70f89-s.woff2'},{'revision':'0ba762bde65aaa682fcc089f8c57288e','url':'/_next/static/media/d29838c109ef09b4-s.woff2'},{'revision':'814fe1615d4dbc7bbd92d26a6bda0905','url':'/_next/static/media/d3ebbfd689654d3a-s.p.woff2'},{'revision':'e71a597e376ed91862e822b5a5de8c04','url':'/_next/static/media/db96af6b531dc71f-s.p.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'9fb609fe01d739bb9df558e1fba2498e','url':'/_next/static/media/e40af3453d7c920a-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'de1d49f26130d43f91829088086625f4','url':'/_next/static/media/ef4d5661765d0e49-s.woff2'},{'revision':'0f8d347d49960d05c9430d83e49edeb7','url':'/_next/static/media/f911b923c6adde36-s.woff2'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'bf670b1fafa1b3127bd50abe86d723b9','url':'/images/player-silhouette.svg'},{'revision':'1cd41b3d92ff160c4635a1ee75bbc34b','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("install",e=>{e.waitUntil(caches.open("offline-fallback").then(e=>e.add(eR)).catch(()=>{}))}),self.addEventListener("activate",e=>{e.waitUntil(caches.keys().then(e=>Promise.all(e.filter(e=>!eE.includes(e)&&!e.startsWith("serwist")).map(e=>(console.log("[SW] deleting stale cache:",e),caches.delete(e))))))}),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:i="/",tag:n="vyndr-notification"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",tag:n,data:{url:i}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new en(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let i,n=r.match({url:e,sameOrigin:t,request:a,event:s});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:r,params:i}}return{}}};let eR="/offline",eE=["pages","api-responses","next-static","static-media","fallback","offline-fallback"],eq=[{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/api/"),handler:new ee({cacheName:"api-responses",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:100,maxAgeSeconds:3600})]})},{matcher:({request:e})=>"navigate"===e.mode,handler:new ee({cacheName:"pages",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400}),{handlerDidError:async()=>await caches.match(eR)||Response.error()}]})},{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/_next/static/"),handler:new ey({cacheName:"next-static",plugins:[new ep({maxEntries:200,maxAgeSeconds:2592e3})]})},{matcher:({url:e})=>e.pathname.startsWith("/images/")||e.pathname.startsWith("/icons/")||/\.(?:png|jpe?g|gif|svg|webp|ico|woff2?)$/.test(e.pathname),handler:new ey({cacheName:"static-media",plugins:[new ep({maxEntries:100,maxAgeSeconds:2592e3})]})},{matcher:()=>!0,handler:new ee({cacheName:"fallback",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400})]})}];new ev({precacheEntries:[{'revision':'e1af5992de55946942117c950b62c8a9','url':'/_next/static/F4OrTmWtp2aLDBZJcPlGF/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/F4OrTmWtp2aLDBZJcPlGF/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1079-4eabb0cad585bc18.js'},{'revision':null,'url':'/_next/static/chunks/1098-7bedeeccda9aece6.js'},{'revision':null,'url':'/_next/static/chunks/1896-4989a39d80811e5e.js'},{'revision':null,'url':'/_next/static/chunks/1942-bc0755f4a7d5b4a6.js'},{'revision':null,'url':'/_next/static/chunks/1958-56cbbb8dba15fd2f.js'},{'revision':null,'url':'/_next/static/chunks/2346-36e36720a5f6591a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-e356ca5ba0218e27.js'},{'revision':null,'url':'/_next/static/chunks/52774a7f.f2ab7a9b4b8ba576.js'},{'revision':null,'url':'/_next/static/chunks/5838-7e0aa455e9f24259.js'},{'revision':null,'url':'/_next/static/chunks/6798-a45efc250736a449.js'},{'revision':null,'url':'/_next/static/chunks/7602.69fd74d0c230bc5a.js'},{'revision':null,'url':'/_next/static/chunks/8488-ee433fb4cc187b8e.js'},{'revision':null,'url':'/_next/static/chunks/8500-f62a38ff68ab7f42.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-9623f3245f088d02.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/about/page-8afe9664ad7a4eeb.js'},{'revision':null,'url':'/_next/static/chunks/app/account/page-d7c4876b410bf217.js'},{'revision':null,'url':'/_next/static/chunks/app/admin/page-658399840377442d.js'},{'revision':null,'url':'/_next/static/chunks/app/api/accuracy/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/admin/stats/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5B...path%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/content/%5B...path%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/desk/pack/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/gamelines/%5Bsport%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hero-prop/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hotlist/%5Bsport%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/mine/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/model/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/lines/%5B...path%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/live/%5Bsport%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/newsletter/subscribe/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/calculate/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/preferences/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/profiles/%5Bhandle%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/profiles/me/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/injuries/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/lineups/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/pitchers/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/slips/parse/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/snapshot/%5Bsport%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/snapshot/summary/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/cascade/%5Bplayer%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/depth/%5Bteam%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/leaders/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/lineup/%5Bteam%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/player/%5Bname%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/streaks/%5Bsport%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/team/%5Babbr%5D/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ticker/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scan-meter/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-f4f0e0ab84373beb.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/compare/page-cc4ed960dc8d07b8.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-d14880f35b7936b8.js'},{'revision':null,'url':'/_next/static/chunks/app/desk/page-83581c06034719cd.js'},{'revision':null,'url':'/_next/static/chunks/app/explore/page-3575eac81a972e46.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-417edd70587875e7.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-717322f0570e19fc.js'},{'revision':null,'url':'/_next/static/chunks/app/help/page-47f525c3fbe18309.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-800f593cb3822819.js'},{'revision':null,'url':'/_next/static/chunks/app/invite/page-1b60aa4dd8c6a128.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-c77c4f0dbb2864ce.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-c49f29df54cb2f27.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-61f418278beb6eb1.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-351e11234fedc80c.js'},{'revision':null,'url':'/_next/static/chunks/app/methodology/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-ff5f51cdd3ab1d2c.js'},{'revision':null,'url':'/_next/static/chunks/app/notifications/page-8afe9664ad7a4eeb.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-bbb5a02a098e58ad.js'},{'revision':null,'url':'/_next/static/chunks/app/onboarding/page-91e04f44793cd8a6.js'},{'revision':null,'url':'/_next/static/chunks/app/opengraph-image/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/page-827aa642fe678f1e.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/layout-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/opengraph-image/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/page-9d1d1f5789396f5f.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-1450fdec38c70c87.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-85426abe5bb4c454.js'},{'revision':null,'url':'/_next/static/chunks/app/report/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-0e5dd187798f1793.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/page-a14c74d4749d0cd6.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-8108967d3acf9712.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-5eaca8162ead7c86.js'},{'revision':null,'url':'/_next/static/chunks/app/slip/page-acf7ba543e84a3a0.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-322565d7c6d9aab2.js'},{'revision':null,'url':'/_next/static/chunks/app/team/%5Babbr%5D/page-4c23ad3e130c192f.js'},{'revision':null,'url':'/_next/static/chunks/app/terminal/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-b847641cba173706.js'},{'revision':null,'url':'/_next/static/chunks/app/twitter-image/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/u/%5Bhandle%5D/opengraph-image/route-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/app/u/%5Bhandle%5D/page-832cae6005fcf484.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-6f1f8f3f5e36929c.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-e3778112a67efb58.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-09bed81017c7c184.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-a92f43f6a50b26d9.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-8ddf34bd5be603df.js'},{'revision':null,'url':'/_next/static/chunks/framework-95da69ac6843d788.js'},{'revision':null,'url':'/_next/static/chunks/main-a354262253293123.js'},{'revision':null,'url':'/_next/static/chunks/main-app-11cae159af32a228.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-98173a0cfbbc164f.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-656eb6ce6d5e64a7.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-98173a0cfbbc164f.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-069386c5e7f81e49.js'},{'revision':null,'url':'/_next/static/css/a9aa2109ad868d00.css'},{'revision':'cd34c7793d9776838308cfbe5d93922b','url':'/_next/static/media/011e180705008d6f-s.woff2'},{'revision':'324703f03c390d2e2a4f387de85fe63d','url':'/_next/static/media/0aa834ed78bf6d07-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'7de6d31e5076eb437418b6e01998ee12','url':'/_next/static/media/20535187d867b7b9-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'0d11e07b5aaafda6ae2efc53256932ec','url':'/_next/static/media/37786be940ec402b-s.woff2'},{'revision':'5de0bff80f9f432b5cf52c1c2f80fe20','url':'/_next/static/media/46e154b2fcbd6033-s.woff2'},{'revision':'6041b3192dc3e1a50141d5d418a24199','url':'/_next/static/media/5356a6a4f2c8c8d8-s.woff2'},{'revision':'f572f7b57d27ec7fd8373961f0b762b0','url':'/_next/static/media/58f386aa6b1a2a92-s.woff2'},{'revision':'e829cbe042e8b2b2e6b99c0eec9e230d','url':'/_next/static/media/656feb427634a431-s.woff2'},{'revision':'54f02056e07c55023315568c637e3a96','url':'/_next/static/media/67957d42bae0796d-s.woff2'},{'revision':'e220ad1849c282590e47db0ff88fc3f3','url':'/_next/static/media/704b853f32d191d5-s.woff2'},{'revision':'662cde3543f3375874e26c26845cec22','url':'/_next/static/media/73cb51aac9c97f90-s.woff2'},{'revision':'afac1fab355fe8db5c2dcaa7623dffb3','url':'/_next/static/media/7ba5fb2a8c88521c-s.woff2'},{'revision':'c94e6e6c23e789fcb0fc60d790c9d2c1','url':'/_next/static/media/886030b0b59bc5a7-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'17a5b2a15bf5647b49cadb9a3bb02e1c','url':'/_next/static/media/92eeb95d069020cc-s.woff2'},{'revision':'4a4e74bed5809194e4bc6538eb1a1e30','url':'/_next/static/media/939c4f875ee75fbb-s.woff2'},{'revision':'6708425c446b05de1c2d471a0eaf4099','url':'/_next/static/media/98e207f02528a563-s.woff2'},{'revision':'13dface50f6dc0bff1015561c84119e6','url':'/_next/static/media/991629005c80bdf1-s.woff2'},{'revision':'216ee1a35b060106ed2852cdd8c026c7','url':'/_next/static/media/99dcf268bda04fe5-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'782150e6836b9b074d1a798807adcb18','url':'/_next/static/media/bb3ef058b751a6ad-s.p.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'75fa2e527eaae805bbf44e3378d1232c','url':'/_next/static/media/d26bbd13d6b70f89-s.woff2'},{'revision':'0ba762bde65aaa682fcc089f8c57288e','url':'/_next/static/media/d29838c109ef09b4-s.woff2'},{'revision':'814fe1615d4dbc7bbd92d26a6bda0905','url':'/_next/static/media/d3ebbfd689654d3a-s.woff2'},{'revision':'e71a597e376ed91862e822b5a5de8c04','url':'/_next/static/media/db96af6b531dc71f-s.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'9fb609fe01d739bb9df558e1fba2498e','url':'/_next/static/media/e40af3453d7c920a-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'de1d49f26130d43f91829088086625f4','url':'/_next/static/media/ef4d5661765d0e49-s.woff2'},{'revision':'0f8d347d49960d05c9430d83e49edeb7','url':'/_next/static/media/f911b923c6adde36-s.woff2'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'bf670b1fafa1b3127bd50abe86d723b9','url':'/images/player-silhouette.svg'},{'revision':'1cd41b3d92ff160c4635a1ee75bbc34b','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("install",e=>{e.waitUntil(caches.open("offline-fallback").then(e=>e.add(eR)).catch(()=>{}))}),self.addEventListener("activate",e=>{e.waitUntil(caches.keys().then(e=>Promise.all(e.filter(e=>!eE.includes(e)&&!e.startsWith("serwist")).map(e=>(console.log("[SW] deleting stale cache:",e),caches.delete(e))))))}),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:i="/",tag:n="vyndr-notification"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",tag:n,data:{url:i}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file diff --git a/web/src/app/api/live/[sport]/route.ts b/web/src/app/api/live/[sport]/route.ts new file mode 100644 index 0000000..1771236 --- /dev/null +++ b/web/src/app/api/live/[sport]/route.ts @@ -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 }); + } +} diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index 4a06068..2ab4b71 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -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 } +interface LiveGame { id: string; home?: string | null; away?: string | null; progress?: { label?: string; fraction?: number } | null; players?: Record } +interface LiveResponse { sport?: string; hasLive?: boolean; games?: LiveGame[] } +type LiveIndex = ReturnType; + +// Sports with a free live box feed wired (specs/LIVE-TRACKING.md). +const LIVE_TRACK_SPORTS = new Set(['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; type DeltaIndex = ReturnType; type PitcherMap = ReturnType; -function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters[5] = null): GameCardData { +function slateGameToCardData(g: SlateGame, gradeIndex: GradeIndex, deltaIndex: DeltaIndex, pitcherMap: PitcherMap, statFilter: string = 'all', viability: Parameters[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([]); // Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire). const [viability, setViability] = useState<{ lineups?: { byPlayer: Record; postedTeams: string[] }; injuries?: Record } | 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>({}); // 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(); + 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 = {}; + 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(() => { + 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 && }
- {filteredGames.map((g, i) => ( + {orderedGames.map((g, i) => ( router.push('/scan')} /> diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index ef958c2..6f46e2e 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -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. */}
- GRADED PROPS + + GRADED PROPS + {isTracking && ( + + · TRACKING — READ LOCKED PRE-GAME + + )} + {g.playerStrips && g.playerStrips.length > 0 ? (
{stripsToRender.map((ps, i) => ( diff --git a/web/src/components/vyndr/StatStrip.tsx b/web/src/components/vyndr/StatStrip.tsx index 81885a7..9bda785 100644 --- a/web/src/components/vyndr/StatStrip.tsx +++ b/web/src/components/vyndr/StatStrip.tsx @@ -38,6 +38,66 @@ export interface StripProp { // (true = cleared). Both absent → nothing renders. history?: Array<{ t: string; line: number }> | null; last10Dots?: boolean[] | null; + // A1 S11 — the canonical stat key (live-tracking join; `stat` is the short + // display label) + the live proto-outcome computed by lib/liveProgress. + // GRADES NEVER CHANGE IN-GAME — `live` is TRACKING in the outcome slot. + statType?: string; + live?: { + current: number; + line: number | null; + state: 'hit' | 'on_pace' | 'needs' | 'holding' | 'past' | string; + label: string; + needs?: number; + progressLabel?: string | null; + progressFraction?: number | null; + } | null; +} + +/** A1 S11 — LIVE TRACKING mark (ROW-GRAMMAR §2 slot 6, proto-outcome). + * `1/2 TB · ▲6th` + a small game-progress bar + the state chip. COLOR LAW: + * green = hit/on-pace/holding, amber = needs-more/line-passed. NEVER red — + * an in-progress prop has settled nothing. Data → mono, never glitches. */ +export function LiveTracker({ p }: { p: StripProp }) { + const lv = p.live; + if (!lv || p.outcome) return null; + const green = lv.state === 'hit' || lv.state === 'on_pace' || lv.state === 'holding'; + const color = green ? 'var(--g-a, #00D4A0)' : 'var(--amber, #FFB347)'; + const filled = lv.state === 'hit'; + const frac = typeof lv.progressFraction === 'number' ? Math.min(1, Math.max(0, lv.progressFraction)) : null; + const titles: Record = { + hit: 'The over has already cleared the locked line — settles when the game is final', + on_pace: 'Current pace projects past the locked line', + needs: 'Behind the locked line at the current pace', + holding: 'The under holds if the count stays below the line — nothing is final until the game is', + past: 'The count reached the line — the under can no longer clear; settles when final', + }; + return ( + + + {lv.current}/{lv.line != null ? lv.line : '—'} {p.stat} + {lv.progressLabel ? · {lv.progressLabel} : null} + + {frac != null && ( + + + + )} + + {lv.label} + + + ); } /** ROW-GRAMMAR §4 — ●/○ last-10 dot strip. Filled green = that game's stat @@ -398,9 +458,17 @@ export default function StatStrip({ ) )} + {/* ROW-GRAMMAR slot 6 — the outcome slot: live TRACKING + proto-outcome while in-progress, settled chip once + final (mutually exclusive — LiveTracker self-hides on + outcome). A1 S11. */} + {!p.dead && } - {!p.outcome && !p.dead && } - {!p.outcome && !p.dead && } + {/* ROW-GRAMMAR slot 7 — actions are suppressed once the + game is LIVE (the pre-game market for the locked line + is closed), dead, or settled. */} + {!p.outcome && !p.dead && !p.live && } + {!p.outcome && !p.dead && !p.live && } {p.gradedAt?.ago && ( Graded {p.gradedAt.ago}{p.gradedAt.odds != null ? ` at ${p.gradedAt.odds}` : ''} diff --git a/web/src/lib/liveProgress.js b/web/src/lib/liveProgress.js new file mode 100644 index 0000000..7a11961 --- /dev/null +++ b/web/src/lib/liveProgress.js @@ -0,0 +1,200 @@ +/* ============================================================ + VYNDR — live progress engine (A1 board, Session 11). + + LIVE TRACKING math + the strip join. The read is locked pre-game; + these marks are PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome + slot (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6). + GRADES NEVER CHANGE IN-GAME. + + Plain CommonJS so the .tsx components import it (allowJs) AND the + plain-JS Jest suite exercises every branch directly. + + COLOR LAW (one meaning per color): green = on-pace / already-cleared / + holding; amber = needs-more / line-passed caution. Red is RESERVED for + settled-negative truth — an in-progress prop is NEVER red. + + DATA SEMANTICS: a player not in the box has no live entry → no mark, + never a fabricated 0. All numeric paths are strict-null. + ============================================================ */ + +const { nameKey } = require('./playerName'); + +/** Strict numeric read — null when absent/unparseable, never 0-by-default. */ +function numOrNull(v) { + if (v == null || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** + * Pure prop-state math. { side, line, current, progress } → state or null. + * + * over, current > line → 'hit' (✓ the over has already cleared + * — a counting stat cannot un-clear; still + * TRACKING until the settle pass owns it) + * over, projected to clear → 'on_pace' (green) + * over, otherwise → 'needs' (amber, NEEDS N) + * under, current < line → 'holding' (green HOLDS — an under is + * never 'hit' until final) + * under, current ≥ line → 'past' (amber LINE PASSED — not red; + * nothing settles until the game is final) + * current/line missing → null (absent beats wrong) + * + * NEEDS N beats a push on integer lines: N = floor(line) + 1 − current, + * ceil'd for fractional stats (IP thirds) — over-strict amber beats an + * over-claimed green. `progress` is the game fraction (innings/9, quarters/4); + * on-pace = current / progress ≥ floor(line) + 1. No progress → no pace + * judgement (stays NEEDS N — honest, not optimistic). + */ +function propState({ side, line, current, progress } = {}) { + const ln = numOrNull(line); + const cur = numOrNull(current); + if (ln == null || cur == null) return null; + const under = /^u/i.test(String(side || 'O')); + if (!under) { + if (cur > ln) return { state: 'hit', label: 'HIT ✓', needs: 0 }; + const clearAt = Math.floor(ln) + 1; + const needs = Math.max(1, Math.ceil(clearAt - cur - 1e-9)); + const p = numOrNull(progress); + const onPace = p != null && p > 0 && cur / p >= clearAt; + return onPace + ? { state: 'on_pace', label: 'ON PACE', needs } + : { state: 'needs', label: `NEEDS ${needs}`, needs }; + } + if (cur < ln) return { state: 'holding', label: 'HOLDS' }; + return { state: 'past', label: 'LINE PASSED' }; +} + +/** + * Flatten /api/live/:sport response(s) → one join index: + * { hasLive, count, players: { [nameKey]: { name, team, values, progress, gameId } } }. + * Accepts a single response or an array (the Slate merges sports). + */ +function buildLiveIndex(responses) { + const list = Array.isArray(responses) ? responses : [responses]; + const players = {}; + let hasLive = false; + let count = 0; + for (const resp of list) { + if (!resp) continue; + if (resp.hasLive) hasLive = true; + for (const g of resp.games || []) { + for (const [key, rec] of Object.entries(g.players || {})) { + if (!rec) continue; + players[key] = { + name: rec.name, + team: rec.team || null, + values: rec.values || {}, + progress: g.progress || null, + gameId: g.id, + }; + count += 1; + } + } + } + return { hasLive, count, players }; +} + +/** + * Join built player strips ↔ the live index (PURE). Only GRADED, unsettled, + * non-dead props get `prop.live` — settled outcomes, dead reads and awaiting + * rows are untouched, and a player absent from the box gets NO mark. The + * state is computed against the LOCKED line (gradedAt.line when present) — + * the read never re-grades. + */ +function attachLiveProgress(strips, liveIndex) { + const idx = liveIndex && liveIndex.players ? liveIndex.players : null; + if (!Array.isArray(strips) || !idx || Object.keys(idx).length === 0) return strips || []; + return strips.map((strip) => { + const entry = idx[nameKey(strip.player)]; + if (!entry) return strip; + const fraction = entry.progress ? numOrNull(entry.progress.fraction) : null; + let touched = false; + const props = (strip.props || []).map((p) => { + if (!p || !p.grade || p.outcome || p.dead || p.awaiting) return p; + const st = String(p.statType || '').toLowerCase(); + if (!st) return p; + const current = entry.values ? numOrNull(entry.values[st]) : null; + if (current == null) return p; // not in the box for this stat — absent + const line = p.gradedAt && numOrNull(p.gradedAt.line) != null ? p.gradedAt.line : p.line; + const state = propState({ side: p.side, line, current, progress: fraction }); + if (!state) return p; + touched = true; + return { + ...p, + live: { + current, + line: numOrNull(line), + ...state, + progressLabel: entry.progress ? entry.progress.label || null : null, + progressFraction: fraction, + }, + }; + }); + return touched ? { ...strip, props } : strip; + }); +} + +/** + * Proximity-to-hit for one game's raw odds props (the Slate's sort key). + * A prop is TRACKED when it's snapshot-graded AND its player has a live box + * value for the stat. Proximity (overs only, per spec): current / needed-total + * = current / (floor(line)+1), capped at 1; already-cleared overs count 1. + * Unders count as tracked but contribute no over-proximity. + * Returns { tracked, proximity }. + */ +function gameLiveProximity(rawProps, gradeIndex, liveIndex) { + const idx = liveIndex && liveIndex.players ? liveIndex.players : null; + if (!Array.isArray(rawProps) || !idx || !gradeIndex) return { tracked: false, proximity: 0 }; + let tracked = false; + let proximity = 0; + for (const p of rawProps) { + if (!p || !p.player) continue; + const stat = String(p.stat_type || p.stat || '').toLowerCase(); + const key = nameKey(p.player); + const rec = gradeIndex[`${key}|${stat}`]; + if (!rec || !rec.grade) continue; + const entry = idx[key]; + if (!entry) continue; + const current = entry.values ? numOrNull(entry.values[stat]) : null; + if (current == null) continue; + tracked = true; + const under = /^u/i.test(String(rec.direction || 'over')); + if (under) continue; + const line = rec.gradedAt && numOrNull(rec.gradedAt.line) != null ? rec.gradedAt.line : rec.line; + const ln = numOrNull(line); + if (ln == null) continue; + const clearAt = Math.floor(ln) + 1; + const frac = clearAt > 0 ? Math.min(1, current / clearAt) : 0; + if (frac > proximity) proximity = frac; + } + return { tracked, proximity }; +} + +/** + * Stable partition sort: items whose scorer says tracked float to the top, + * ordered by proximity desc; everything else keeps its original order. + */ +function sortLiveFirst(items, scorer) { + if (!Array.isArray(items) || typeof scorer !== 'function') return items || []; + const scored = items.map((it, i) => { + const s = scorer(it) || { tracked: false, proximity: 0 }; + return { it, i, tracked: !!s.tracked, proximity: numOrNull(s.proximity) || 0 }; + }); + return scored + .sort((a, b) => { + if (a.tracked !== b.tracked) return a.tracked ? -1 : 1; + if (a.tracked && b.tracked && b.proximity !== a.proximity) return b.proximity - a.proximity; + return a.i - b.i; + }) + .map((x) => x.it); +} + +module.exports = { + propState, + buildLiveIndex, + attachLiveProgress, + gameLiveProximity, + sortLiveFirst, + numOrNull, +}; diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index 03be4ce..12f4ea8 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -365,6 +365,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat const delta = deltaIndex[`${gradeKey(p.player, p.stat_type || p.stat)}|${side}`]; byPlayer[pk].props.push({ stat: statShort(rec.stat_type || rec.stat), + // A1 S11 — the CANONICAL stat key (live-tracking join; `stat` above is + // the shortened display label and can't be joined on). + statType: String(rec.stat_type || rec.stat || '').toLowerCase(), line: rec.line, side, grade: rec.grade, @@ -391,7 +394,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat }); } else { byPlayer[pk].props.push({ - stat: statShort(p.stat_type || p.stat), line: p.line, side: '', grade: null, awaiting: true, + stat: statShort(p.stat_type || p.stat), + statType: String(p.stat_type || p.stat || '').toLowerCase(), + line: p.line, side: '', grade: null, awaiting: true, book: p.book || null, bestBook: detectBestBook(p.books, p.direction || 'over', p.line), });