From 0538205fab751406bd99c0b9c92d82aa2295a365 Mon Sep 17 00:00:00 2001 From: Kev Date: Fri, 12 Jun 2026 11:16:58 -0400 Subject: [PATCH] =?UTF-8?q?Session=2023:=20All-day=20intelligence=20layer?= =?UTF-8?q?=20=E2=80=94=20schedule,=20game=20lines,=20streaks,=20hot=20lis?= =?UTF-8?q?ts,=20stat=20filtering,=20ParlayAPI=20dead=20(1567=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BUILD-STATE.md | 91 +++++++ CLAUDE.md | 16 ++ data/training/resolutions-2026-06.jsonl | 21 ++ src/app.js | 12 + src/config/providers.js | 16 +- src/config/statFilters.js | 35 +++ src/routes/gameLines.js | 144 ++++++++++ src/routes/hotlist.js | 39 +++ src/routes/schedule.js | 56 ++++ src/routes/stats.js | 9 + src/routes/streaks.js | 43 +++ src/services/adapters/tank01MlbAdapter.js | 23 ++ src/services/hotListService.js | 140 ++++++++++ src/services/rosterLogs.js | 95 +++++++ src/services/scheduleService.js | 182 +++++++++++++ src/services/streaksService.js | 253 ++++++++++++++++++ tests/integration/gameLinesRoute.test.js | 98 +++++++ tests/integration/scheduleRoute.test.js | 127 +++++++++ .../integration/streaksHotlistRoutes.test.js | 90 +++++++ tests/unit/hotListService.test.js | 82 ++++++ tests/unit/providersRegistry.test.js | 54 ++++ tests/unit/rosterLogs.test.js | 60 +++++ tests/unit/statFilters.test.js | 51 ++++ tests/unit/streaksService.test.js | 130 +++++++++ tests/unit/tank01MlbAdapter.test.js | 23 ++ web/public/sw.js | 2 +- web/src/app/page.tsx | 9 + web/src/components/HotListPanel.tsx | 123 +++++++++ web/src/components/Slate.tsx | 23 ++ web/src/components/StatFilterPills.tsx | 71 +++++ web/src/components/StreaksPanel.tsx | 121 +++++++++ web/src/config/statFilters.ts | 39 +++ 32 files changed, 2276 insertions(+), 2 deletions(-) create mode 100644 src/config/statFilters.js create mode 100644 src/routes/gameLines.js create mode 100644 src/routes/hotlist.js create mode 100644 src/routes/schedule.js create mode 100644 src/routes/streaks.js create mode 100644 src/services/hotListService.js create mode 100644 src/services/rosterLogs.js create mode 100644 src/services/scheduleService.js create mode 100644 src/services/streaksService.js create mode 100644 tests/integration/gameLinesRoute.test.js create mode 100644 tests/integration/scheduleRoute.test.js create mode 100644 tests/integration/streaksHotlistRoutes.test.js create mode 100644 tests/unit/hotListService.test.js create mode 100644 tests/unit/providersRegistry.test.js create mode 100644 tests/unit/rosterLogs.test.js create mode 100644 tests/unit/statFilters.test.js create mode 100644 tests/unit/streaksService.test.js create mode 100644 web/src/components/HotListPanel.tsx create mode 100644 web/src/components/StatFilterPills.tsx create mode 100644 web/src/components/StreaksPanel.tsx create mode 100644 web/src/config/statFilters.ts diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 7338b1d..0bf8e88 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,6 +4,97 @@ 2026-06-12 ## Current Phase +SHIP BUILD v23.0 — All-Day Intelligence Layer: schedule, game lines, streaks, hot lists, stat filtering (Session 23) + +## Session 23 (2026-06-12) — SHIPPED + +Built the all-day content layer that makes VYNDR an intelligence +terminal, not a prop-grading widget. EVERYTHING coexists: schedule, +stats, streaks, hot lists, and game lines all visible at once — +nothing replaces anything. When odds-api props are empty, the other +(free/cheap) layers keep the platform alive. NO odds-api credits were +spent this session. + +Baseline 1505 → **1567 tests** (+62), 124 suites, zero regressions. +Web build clean. + +### PHASE 1 — Schedule API (`/api/schedule/:sport`) +- `src/services/scheduleService.js` — cache-aside read of free ESPN + scoreboards. Reads `schedule:{sport}:{date}` first; on a miss it + self-heals by fetching ESPN directly (the same free endpoint the + pollers hit), normalizes, caches 60s. The platform is NEVER empty. +- Per-game `hasOdds` / `hasGameLines` flags read OTHER caches + (odds-api props, Tank01 lines) WITHOUT triggering a fetch. +- `src/routes/schedule.js` — returns an empty slate (never 5xx) on + error. Unknown sport → 404. Mounted in `app.js`. + +### PHASE 2 — Tank01 Game Lines (`/api/gamelines/:sport`) +- Added `getMLBBettingOdds` to `tank01MlbAdapter` (NBA already had + `getNBABettingOdds`). 15-min cache TTL, shares RAPID_API_KEY quota. +- `src/routes/gameLines.js` — normalizes the book-by-book body + (bet365 / betmgm / caesars: ML, spread, total) into a flat shape, + parses teams from the `YYYYMMDD_AWAY@HOME` gameID. Missing key → + graceful `configured:false`. Adapter throw → empty, never 500. + +### PHASE 3 — Streaks Engine (`/api/streaks/:sport`) +- `src/services/streaksService.js` — pure, data-driven. Consecutive + run from the latest game backward; collapses tiered specs (25+/20+ + pts) to the more impressive one. NBA (14 specs incl. dd/td/PRA/hot- + shooter), MLB (9), NFL (5), soccer (4). Through VYNDR's lens — + "4-game 28+ scoring streak", not "31 PPG". +- `src/services/rosterLogs.js` — Redis-only roster loader (prefetch + blob fast-path, else SCAN over `gamelogs:{sport}:*`). Never throws. + +### PHASE 4 — Hot Lists (`/api/hotlist/:sport`) +- `src/services/hotListService.js` — "hot" = ABOVE the player's own + baseline (explicit seasonAvg, else games outside the window), not + just high raw numbers. Ranked by delta, tie-broken by raw recent + average. Date-based 7-day window when rows carry dates. + +### PHASE 5 — Stat Filtering +- `src/config/statFilters.js` (+ `web/src/config/statFilters.ts` + mirror). `?stat=` param on streaks/hotlist. Discovery endpoint + `GET /api/stats/filters/:sport`. `StatFilterPills` component. + +### PHASE 6 — Unified Dashboard +- `StreaksPanel` + `HotListPanel` (headshots, tier-gated, self-hide + when empty), wired into the Slate below the games AND mounted as + landing-page teasers. Stat pills narrow both; schedule + game lines + stay visible regardless. Free tier sees 3, paid sees all. + +### PHASE 7 — Cleanup +- ParlayAPI marked `status: 'dead'` in `src/config/providers.js` + (Chrome Claude: `api.parlayapi.io` unreachable on 2026-06-12). + Excluded from `getFallbackChain` + `getConfiguredProviders`; new + `isDeadProvider` helper. Config still resolves so adapter tests + (network-mocked) pass unchanged. + +### Files created +- `src/services/scheduleService.js`, `src/routes/schedule.js` +- `src/routes/gameLines.js` +- `src/services/streaksService.js`, `src/routes/streaks.js` +- `src/services/hotListService.js`, `src/routes/hotlist.js` +- `src/services/rosterLogs.js` +- `src/config/statFilters.js` +- `web/src/config/statFilters.ts` +- `web/src/components/StatFilterPills.tsx` +- `web/src/components/StreaksPanel.tsx` +- `web/src/components/HotListPanel.tsx` +- 7 new test files (schedule, gamelines, streaks/hotlist routes, + streaksService, hotListService, rosterLogs, statFilters, + providersRegistry) + +### Files modified +- `src/app.js` (mounted 4 routes) +- `src/services/adapters/tank01MlbAdapter.js` (getMLBBettingOdds) +- `src/routes/stats.js` (filters discovery endpoint) +- `src/config/providers.js` (ParlayAPI dead) +- `web/src/app/page.tsx`, `web/src/components/Slate.tsx` +- `tests/unit/tank01MlbAdapter.test.js` + +--- + +## Previous Phase SHIP BUILD v22.0 — Tracker-driven quota guard, env-configurable cache TTL, opt-in odds prewarmer (Session 22) ## Session 22 (2026-06-12) — SHIPPED diff --git a/CLAUDE.md b/CLAUDE.md index 5c776e8..50a0067 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,22 @@ vyndr/ └── DECISIONS.md # Architecture decisions log ``` +## All-Day Intelligence Layer (Session 23) +Free/cheap content that keeps the platform alive when odds-api props are +empty. NONE of these spend odds-api credits: +- `/api/schedule/:sport` — cache-aside ESPN scoreboard (`scheduleService`), + self-heals on cache miss. Per-game `hasOdds`/`hasGameLines` flags peek at + other caches without fetching. +- `/api/gamelines/:sport` — Tank01 book-by-book lines (RAPID_API_KEY quota). +- `/api/streaks/:sport` + `/api/hotlist/:sport` — PURE engines + (`streaksService`, `hotListService`) computed from cached game logs. NO + API calls. Logs loaded by `rosterLogs.js` (prefetch blob, else Redis SCAN + over `gamelogs:{sport}:{player}:{count}`). Empty roster = valid empty state. +- `?stat=` filters narrow streaks/hotlist; categories in `config/statFilters.js` + (mirror `web/src/config/statFilters.ts`). Discovery: `/api/stats/filters/:sport`. +- Dead providers: set `status: 'dead'` in `config/providers.js` to drop a + provider from fallback chains + configured list (ParlayAPI host is dead). + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/data/training/resolutions-2026-06.jsonl b/data/training/resolutions-2026-06.jsonl index eb280c1..9d3f842 100644 --- a/data/training/resolutions-2026-06.jsonl +++ b/data/training/resolutions-2026-06.jsonl @@ -654,3 +654,24 @@ {"ts":"2026-06-12T06:35:20.178Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} {"ts":"2026-06-12T06:35:20.178Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} {"ts":"2026-06-12T06:35:20.225Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-12T14:34:50.184Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-12T14:34:50.222Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"} +{"ts":"2026-06-12T14:34:50.223Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} +{"ts":"2026-06-12T14:34:50.223Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} +{"ts":"2026-06-12T14:34:50.273Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-12T14:34:50.275Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} +{"ts":"2026-06-12T14:34:50.794Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-12T14:56:32.379Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"} +{"ts":"2026-06-12T14:56:32.379Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} +{"ts":"2026-06-12T14:56:32.379Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} +{"ts":"2026-06-12T14:56:32.429Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-12T14:56:32.997Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-12T14:56:33.076Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} +{"ts":"2026-06-12T14:56:33.668Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-12T14:57:49.552Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"} +{"ts":"2026-06-12T14:57:49.552Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"} +{"ts":"2026-06-12T14:57:49.552Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"} +{"ts":"2026-06-12T14:57:49.582Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"} +{"ts":"2026-06-12T14:57:50.503Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} +{"ts":"2026-06-12T14:57:50.614Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"} +{"ts":"2026-06-12T14:57:50.736Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"} diff --git a/src/app.js b/src/app.js index 5fd409e..58b5e05 100644 --- a/src/app.js +++ b/src/app.js @@ -138,6 +138,18 @@ app.use('/api/grading', express.json({ limit: '10mb' }), gradingRoutes); app.use('/api/grading', express.json({ limit: '256kb' }), correctionRoutes); const widgetRoutes = require('./routes/widget'); app.use('/api/widget', widgetRoutes); +// Session 23 — all-day intelligence layer. Free/cheap content surfaces +// that keep the platform alive when odds-api is empty: schedule (ESPN), +// game lines (Tank01), streaks + hot lists (cached game logs), and the +// stat-filtered views over all of them. +const scheduleRoutes = require('./routes/schedule'); +app.use('/api/schedule', scheduleRoutes); +const gameLinesRoutes = require('./routes/gameLines'); +app.use('/api/gamelines', gameLinesRoutes); +const streaksRoutes = require('./routes/streaks'); +app.use('/api/streaks', streaksRoutes); +const hotListRoutes = require('./routes/hotlist'); +app.use('/api/hotlist', hotListRoutes); // Session 18 — internal ops endpoints (admin dashboard triggers, // shared-key auth via `VYNDR_INTERNAL_KEY`). Never reachable from // the public surface; the Next.js admin route proxies through with diff --git a/src/config/providers.js b/src/config/providers.js index d51dc19..eca18fb 100644 --- a/src/config/providers.js +++ b/src/config/providers.js @@ -61,9 +61,16 @@ const PROVIDERS = { // /historical/player_props → hit rate enrichment // /historical/closing_lines → CLV reference // Base URL: https://api.parlayapi.io/v1. Auth: X-Api-Key header. + // Session 23 — DEAD. Chrome Claude confirmed on 2026-06-12 that + // `api.parlayapi.io` no longer resolves (domain unreachable). We keep + // the entry so the adapter code + its (network-mocked) tests still + // resolve a config, but `status: 'dead'` removes it from every + // fallback chain and the configured-providers list, so the gateway + // never routes a live call to a host that doesn't exist. 'parlayapi': { name: 'ParlayAPI (historical)', envKey: 'PARLAYAPI_KEY', + status: 'dead', quotaType: 'monthly', quotaLimit: 1000, resetDay: 1, @@ -131,10 +138,15 @@ function listProviderIds() { */ function getConfiguredProviders() { return Object.entries(PROVIDERS) - .filter(([, cfg]) => !!process.env[cfg.envKey]) + .filter(([, cfg]) => !!process.env[cfg.envKey] && cfg.status !== 'dead') .map(([id, cfg]) => ({ id, ...cfg })); } +/** True when a provider is retired (e.g. its host no longer resolves). */ +function isDeadProvider(providerId) { + return PROVIDERS[providerId]?.status === 'dead'; +} + /** * Fallback chain for a capability + sport, in priority order, * excluding `excludeId`. Used by the gateway to walk down to the @@ -144,6 +156,7 @@ function getFallbackChain(capability, sport, excludeId) { return Object.entries(PROVIDERS) .filter(([id, cfg]) => id !== excludeId && + cfg.status !== 'dead' && // Session 23 — skip retired providers cfg.capabilities.includes(capability) && (!sport || cfg.sports.includes(sport)) && !!process.env[cfg.envKey], @@ -159,4 +172,5 @@ module.exports = { listProviderIds, getConfiguredProviders, getFallbackChain, + isDeadProvider, }; diff --git a/src/config/statFilters.js b/src/config/statFilters.js new file mode 100644 index 0000000..595a9b3 --- /dev/null +++ b/src/config/statFilters.js @@ -0,0 +1,35 @@ +'use strict'; + +/** + * Stat-filter categories per sport (Session 23). + * + * The stat filter is VYNDR's navigation system — users browse by what + * they care about ("show me everyone on a 3-point streak"), not by sport + * alone. These categories drive: + * - the StatFilterPills UI + * - the `?stat=` param on /api/streaks and /api/hotlist + * + * Category strings here MUST match the `category` field the streaks & + * hot-list engines emit, or the filter silently returns nothing. Mirror + * any change in `web/src/config/statFilters.ts`. + */ + +const STAT_FILTERS = Object.freeze({ + nba: ['all', 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra'], + wnba: ['all', 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals'], + mlb: ['all', 'hits', 'home_runs', 'stolen_bases', 'rbis', 'strikeouts', 'total_bases', 'on_base'], + soccer: ['all', 'goals', 'assists', 'shots', 'tackles', 'saves'], + nfl: ['all', 'passing_yards', 'rushing_yards', 'receiving_yards', 'touchdowns', 'interceptions'], +}); + +function getStatFilters(sport) { + return STAT_FILTERS[String(sport || '').toLowerCase()] || ['all']; +} + +/** Is `stat` a valid category for `sport`? 'all' is always valid. */ +function isValidStat(sport, stat) { + if (!stat || stat === 'all') return true; + return getStatFilters(sport).includes(String(stat).toLowerCase()); +} + +module.exports = { STAT_FILTERS, getStatFilters, isValidStat }; diff --git a/src/routes/gameLines.js b/src/routes/gameLines.js new file mode 100644 index 0000000..805c425 --- /dev/null +++ b/src/routes/gameLines.js @@ -0,0 +1,144 @@ +/** + * /api/gamelines/:sport (Session 23) + * + * Today's game-level betting odds from Tank01 — book-by-book moneylines, + * run/point spreads, and totals. Separate budget from odds-api player + * props: this uses the RAPID_API_KEY quota via the Tank01 adapters. + * + * Chrome Claude confirmed (2026-06-12) Tank01 MLB serves LIVE lines from + * bet365 / betmgm / caesars. NBA carries the same in-season — an empty + * result off-season is correct, not an error. + * + * Response shape: + * { + * sport: 'mlb', + * date: '2026-06-12', + * games: { + * '20260612_ARI@CIN': { + * homeTeam: 'CIN', awayTeam: 'ARI', + * books: { + * bet365: { homeML, awayML, total, overOdds, underOdds, + * homeSpread, awaySpread }, + * betmgm: { ... }, + * }, + * }, + * }, + * source: 'tank01', + * } + */ + +const express = require('express'); +const nbaAdapter = require('../services/adapters/tank01NbaAdapter'); +const mlbAdapter = require('../services/adapters/tank01MlbAdapter'); +const scheduleService = require('../services/scheduleService'); + +const router = express.Router(); + +const MISSION_HEADER = { 'X-VYNDR-Mission': 'Lines keep the slate alive' }; + +// Sports that have a Tank01 game-lines feed wired up. +const FETCHERS = { + nba: (date) => nbaAdapter.getNBABettingOdds(date), + mlb: (date) => mlbAdapter.getMLBBettingOdds(date), +}; + +const HAS_KEY = { + nba: () => nbaAdapter.hasApiKey(), + mlb: () => mlbAdapter.hasApiKey(), +}; + +/** + * Parse `YYYYMMDD_AWAY@HOME` → { awayTeam, homeTeam }. Tank01 keys every + * game this way. Falls back to nulls on an unexpected key. + */ +function teamsFromGameId(gameId) { + const m = String(gameId || '').match(/_([A-Za-z0-9]+)@([A-Za-z0-9]+)/); + if (!m) return { awayTeam: null, homeTeam: null }; + return { awayTeam: m[1], homeTeam: m[2] }; +} + +/** + * Normalize one sportsbook's raw odds object into a flat, UI-ready row. + * Tank01 field names are verbose and occasionally vary; pull defensively. + */ +function normalizeBook(odds) { + if (!odds || typeof odds !== 'object') return null; + const pick = (...keys) => { + for (const k of keys) { + if (odds[k] !== undefined && odds[k] !== null && odds[k] !== '') return odds[k]; + } + return null; + }; + return { + homeML: pick('homeTeamMLOdds', 'homeML', 'moneyLineHome'), + awayML: pick('awayTeamMLOdds', 'awayML', 'moneyLineAway'), + total: pick('totalOver', 'total', 'overUnder'), + overOdds: pick('totalOverOdds', 'overOdds'), + underOdds: pick('totalUnderOdds', 'underOdds'), + homeSpread: pick('homeTeamSpread', 'homeSpread'), + awaySpread: pick('awayTeamSpread', 'awaySpread'), + homeSpreadOdds: pick('homeTeamSpreadOdds'), + awaySpreadOdds: pick('awayTeamSpreadOdds'), + }; +} + +/** + * Normalize the Tank01 betting-odds body (a map keyed by gameID) into the + * route's `games` shape. Defensive against both the documented map form + * and a bare array of game objects. + */ +function normalizeGameLines(body) { + const games = {}; + if (!body || typeof body !== 'object') return games; + + const entries = Array.isArray(body) + ? body.map((g) => [g.gameID || g.gameId, g]) + : Object.entries(body); + + for (const [gameId, game] of entries) { + if (!gameId || !game || typeof game !== 'object') continue; + const { awayTeam, homeTeam } = teamsFromGameId(gameId); + const books = {}; + const sbList = game.sportsBooks || game.books || []; + if (Array.isArray(sbList)) { + for (const sb of sbList) { + const name = sb?.sportsBook || sb?.book || sb?.name; + const row = normalizeBook(sb?.odds || sb); + if (name && row) books[String(name).toLowerCase()] = row; + } + } + games[gameId] = { + homeTeam: game.homeTeam || homeTeam, + awayTeam: game.awayTeam || awayTeam, + books, + }; + } + return games; +} + +router.get('/:sport', async (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + const date = req.query.date || scheduleService.todayET(); + + const fetcher = FETCHERS[sport]; + if (!fetcher) { + return res.status(404).set(MISSION_HEADER).json({ error: `No game lines for sport: ${sport}` }); + } + + // Missing RAPID_API_KEY → graceful empty, never a crash. + if (HAS_KEY[sport] && !HAS_KEY[sport]()) { + return res.set(MISSION_HEADER).json({ sport, date, games: {}, source: 'tank01', configured: false }); + } + + try { + const body = await fetcher(date); + const games = normalizeGameLines(body); + return res.set(MISSION_HEADER).json({ sport, date, games, source: 'tank01' }); + } catch (err) { + console.error(`[gamelines/${sport}]`, err.message); + return res.set(MISSION_HEADER).json({ sport, date, games: {}, source: 'tank01' }); + } +}); + +module.exports = router; +module.exports.__internals = { teamsFromGameId, normalizeBook, normalizeGameLines }; diff --git a/src/routes/hotlist.js b/src/routes/hotlist.js new file mode 100644 index 0000000..623d38c --- /dev/null +++ b/src/routes/hotlist.js @@ -0,0 +1,39 @@ +/** + * /api/hotlist/:sport (Session 23) + * + * Rolling recent-window leaders, ranked by how far ABOVE baseline each + * player is trending. NO API calls — reads warm cached game logs and runs + * the pure hot-list engine. Supports `?stat=points` and `?limit=N`. + * + * Response: { sport, stat, players: [...], source: 'computed' } + */ + +const express = require('express'); +const hotListService = require('../services/hotListService'); +const { loadRosterLogs } = require('../services/rosterLogs'); + +const router = express.Router(); + +const MISSION_HEADER = { 'X-VYNDR-Mission': 'Hot right now' }; + +const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'soccer']); + +router.get('/:sport', async (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + if (!SUPPORTED.has(sport)) { + return res.status(404).set(MISSION_HEADER).json({ error: `No hot list for sport: ${sport}` }); + } + const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all'; + const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0; + + try { + const roster = await loadRosterLogs(sport); + const players = hotListService.computeHotList(roster, sport, { stat, limit }); + return res.set(MISSION_HEADER).json({ sport, stat, players, source: 'computed' }); + } catch (err) { + console.error(`[hotlist/${sport}]`, err.message); + return res.set(MISSION_HEADER).json({ sport, stat, players: [], source: 'computed' }); + } +}); + +module.exports = router; diff --git a/src/routes/schedule.js b/src/routes/schedule.js new file mode 100644 index 0000000..fb6ace4 --- /dev/null +++ b/src/routes/schedule.js @@ -0,0 +1,56 @@ +/** + * /api/schedule/:sport (Session 23) + * + * Returns today's game schedule from cached/free ESPN data. NO odds-api + * credits burned. The PM2 pollers warm this cache every 60s; on a miss + * the schedule service self-heals by fetching the free ESPN scoreboard. + * + * Each game carries two boolean flags read from OTHER caches (no fetch): + * hasOdds — odds-api player props exist for this slate + * hasGameLines — Tank01 game-level lines exist for this slate + * + * Response shape: + * { + * sport: 'nba', + * date: '2026-06-12', + * games: [ { id, homeTeam, awayTeam, gameTime, status, score, + * venue, broadcast, hasOdds, hasGameLines } ], + * source: 'espn', + * } + */ + +const express = require('express'); +const scheduleService = require('../services/scheduleService'); +const { SPORT_CONFIG } = require('../config/sports'); + +const router = express.Router(); + +const MISSION_HEADER = { 'X-VYNDR-Mission': 'The slate is never empty' }; + +router.get('/:sport', async (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + const date = req.query.date || scheduleService.todayET(); + + if (!SPORT_CONFIG[sport]) { + return res.status(404).set(MISSION_HEADER).json({ error: `Unknown sport: ${sport}` }); + } + + try { + const raw = await scheduleService.getSchedule(sport, date); + // null → unsupported sport (already guarded above); treat defensively. + const games = await scheduleService.enrichFlags(sport, date, raw || []); + return res.set(MISSION_HEADER).json({ + sport, + date, + games, + source: 'espn', + }); + } catch (err) { + console.error(`[schedule/${sport}]`, err.message); + // Even on error we return an empty slate, not a 5xx — the platform + // is NEVER down. Other layers (game lines, props) keep it alive. + return res.set(MISSION_HEADER).json({ sport, date, games: [], source: 'espn' }); + } +}); + +module.exports = router; diff --git a/src/routes/stats.js b/src/routes/stats.js index c22725b..d181e4c 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -1,10 +1,19 @@ const express = require('express'); const { getSupabaseServiceClient } = require('../utils/supabase'); +const { getStatFilters } = require('../config/statFilters'); const router = express.Router(); const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' }; +// GET /filters/:sport — stat-filter categories for the StatFilterPills UI +// (Session 23). Lets the frontend stay data-driven without re-declaring +// the category list. NO auth / NO DB — pure config. +router.get('/filters/:sport', (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + res.set(MISSION_HEADER).json({ sport, filters: getStatFilters(sport) }); +}); + // GET /parlays-graded — total scan count router.get('/parlays-graded', async (req, res) => { try { diff --git a/src/routes/streaks.js b/src/routes/streaks.js new file mode 100644 index 0000000..6f37f67 --- /dev/null +++ b/src/routes/streaks.js @@ -0,0 +1,43 @@ +/** + * /api/streaks/:sport (Session 23) + * + * Computed player streaks from cached game logs. NO API calls — reads + * warm Redis logs and runs the pure streaks engine over them. Supports + * `?stat=points` to narrow to one category, and `?limit=N`. + * + * Response: { sport, stat, streaks: [...], source: 'computed' } + * + * An empty `streaks` array is a valid, non-error state — the platform + * leans on the other layers (schedule, game lines, props) when no logs + * are warm yet. + */ + +const express = require('express'); +const streaksService = require('../services/streaksService'); +const { loadRosterLogs } = require('../services/rosterLogs'); + +const router = express.Router(); + +const MISSION_HEADER = { 'X-VYNDR-Mission': 'Streaks are the heartbeat' }; + +const SUPPORTED = new Set(['nba', 'wnba', 'mlb', 'nfl', 'soccer']); + +router.get('/:sport', async (req, res) => { + const sport = String(req.params.sport || '').toLowerCase(); + if (!SUPPORTED.has(sport)) { + return res.status(404).set(MISSION_HEADER).json({ error: `No streaks for sport: ${sport}` }); + } + const stat = req.query.stat ? String(req.query.stat).toLowerCase() : 'all'; + const limit = req.query.limit ? Math.max(0, parseInt(req.query.limit, 10) || 0) : 0; + + try { + const roster = await loadRosterLogs(sport); + const streaks = streaksService.computeStreaks(roster, sport, { stat, limit }); + return res.set(MISSION_HEADER).json({ sport, stat, streaks, source: 'computed' }); + } catch (err) { + console.error(`[streaks/${sport}]`, err.message); + return res.set(MISSION_HEADER).json({ sport, stat, streaks: [], source: 'computed' }); + } +}); + +module.exports = router; diff --git a/src/services/adapters/tank01MlbAdapter.js b/src/services/adapters/tank01MlbAdapter.js index 8b749c0..f49aeb9 100644 --- a/src/services/adapters/tank01MlbAdapter.js +++ b/src/services/adapters/tank01MlbAdapter.js @@ -28,6 +28,7 @@ const TTL = Object.freeze({ boxScoreFinal: 24 * 3600, scoreboard: 1 * 3600, bvp: 24 * 3600, // BvP doesn't change mid-day — 24h cache is fine + odds: 15 * 60, // Session 23 — book-by-book game lines, 15min }); function getHost() { @@ -178,10 +179,32 @@ async function getMLBDailyScoreboard(date) { })); } +/** + * getMLBBettingOdds — Tank01's game-level odds feed (book-by-book). + * Chrome Claude confirmed this serves LIVE moneylines, run lines, and + * totals from bet365 / betmgm / caesars (Session 23). Separate from the + * odds-api player-props pipeline; shares the RAPID_API_KEY quota. + * + * Returns the raw `body` (a map keyed by gameID, each carrying a + * per-sportsbook odds object). The gameLines route normalizes it. + */ +async function getMLBBettingOdds(date) { + if (!date) return null; + const ymd = String(date).replace(/-/g, ''); + const data = await fetchWithCache( + `/getMLBBettingOdds?gameDate=${ymd}`, + `tank01:mlb:odds:${ymd}`, + TTL.odds, + ); + if (data === null) return null; + return data?.body || data; +} + module.exports = { getMLBBoxScore, getMLBBatterVsPitcher, getMLBDailyScoreboard, + getMLBBettingOdds, hasApiKey, __internals: { TTL, diff --git a/src/services/hotListService.js b/src/services/hotListService.js new file mode 100644 index 0000000..cc5dbbe --- /dev/null +++ b/src/services/hotListService.js @@ -0,0 +1,140 @@ +/** + * Hot lists (Session 23). + * + * Rolling recent-window leaders — but through VYNDR's lens. "Hot" does + * NOT mean "highest raw number." It means performing ABOVE the player's + * own baseline in the recent window. A 20-PPG player going 28/31/25 is + * hot; a 30-PPG player who dropped 28 is not. + * + * Baseline preference: + * 1. explicit `player.seasonAvg[stat]` if supplied + * 2. else the player's own games OUTSIDE the recent window (recent vs rest) + * + * If neither baseline is available (a player with only window-length + * history and no season avg) the player is excluded — we can't claim + * "trending up" without something to trend against. + * + * Pure & deterministic. The route supplies cached logs; this does math. + */ + +const { __internals } = require('./streaksService'); +const { nba, mlb, soccer } = __internals; + +// category → accessor fn, per sport. Mirrors STAT_FILTERS categories. +const HOT_STATS = { + nba: { + points: nba.points, rebounds: nba.rebounds, assists: nba.assists, + threes: nba.threes, blocks: nba.blocks, steals: nba.steals, pra: nba.pra, + }, + wnba: { + points: nba.points, rebounds: nba.rebounds, assists: nba.assists, + threes: nba.threes, blocks: nba.blocks, steals: nba.steals, + }, + mlb: { + hits: mlb.hits, home_runs: mlb.homeRuns, stolen_bases: mlb.stolenBases, + rbis: mlb.rbi, total_bases: mlb.totalBases, strikeouts: mlb.strikeouts, + on_base: mlb.onBase, + }, + soccer: { + goals: soccer.goals, assists: soccer.assists, shots: soccer.shotsOnTarget, + }, +}; + +// Headline stat per sport when the caller asks for 'all'. +const DEFAULT_STAT = { nba: 'points', wnba: 'points', mlb: 'hits', soccer: 'goals' }; + +const STAT_LABEL = { + points: 'pts', rebounds: 'reb', assists: 'ast', threes: '3PM', + blocks: 'blk', steals: 'stl', pra: 'PRA', + hits: 'H', home_runs: 'HR', stolen_bases: 'SB', rbis: 'RBI', + total_bases: 'TB', strikeouts: 'K', on_base: 'OB', + goals: 'G', shots: 'SOT', +}; + +function mean(rows, fn) { + if (!rows.length) return 0; + return rows.reduce((acc, r) => acc + fn(r), 0) / rows.length; +} + +function round1(n) { return Math.round(n * 10) / 10; } + +function resolveStat(sport, stat) { + const table = HOT_STATS[sport] || {}; + if (!stat || stat === 'all') return DEFAULT_STAT[sport] || Object.keys(table)[0] || null; + return table[stat] ? stat : null; +} + +/** + * Returns a ranked list of hot players for one stat. + * players = [{ name, playerId, team, games, seasonAvg? }] + * opts = { stat, window=7, limit, now, windowDays } + * + * When rows carry a `date` and `windowDays`+`now` are supplied, the recent + * window is date-based; otherwise it's the last `window` games. + */ +function computeHotList(players, sport, opts = {}) { + const key = String(sport || '').toLowerCase(); + const stat = resolveStat(key, opts.stat); + if (!stat || !Array.isArray(players)) return []; + const fn = HOT_STATS[key][stat]; + const window = opts.window && opts.window > 0 ? opts.window : 7; + + const rows = []; + for (const p of players) { + const games = Array.isArray(p?.games) ? p.games.slice() : []; + if (games.length === 0) continue; + if (opts.chronological) games.reverse(); + + let recent; + let rest; + if (opts.windowDays && opts.now && games[0]?.date) { + const cutoff = opts.now - opts.windowDays * 86_400_000; + recent = games.filter((g) => new Date(g.date).getTime() >= cutoff); + rest = games.filter((g) => new Date(g.date).getTime() < cutoff); + } else { + recent = games.slice(0, window); + rest = games.slice(window); + } + if (recent.length === 0) continue; + + const recentAvg = mean(recent, fn); + + // Baseline: explicit season avg, else the player's older games. + let baseline = null; + const sa = p.seasonAvg && p.seasonAvg[stat]; + if (sa !== undefined && sa !== null && Number.isFinite(Number(sa))) { + baseline = Number(sa); + } else if (rest.length > 0) { + baseline = mean(rest, fn); + } + if (baseline === null) continue; // nothing to trend against + if (recentAvg <= baseline) continue; // not hot — at or below baseline + + const delta = recentAvg - baseline; + rows.push({ + sport: key, + stat, + name: p.name || p.player || null, + playerId: p.playerId ?? p.id ?? null, + team: p.team || null, + recentAvg: round1(recentAvg), + baseline: round1(baseline), + delta: round1(delta), + window: recent.length, + statLine: `${round1(recentAvg)} ${STAT_LABEL[stat] || stat} over last ${recent.length}`, + trendDescription: `+${round1(delta)} above ${round1(baseline)} avg`, + }); + } + + // Rank by how far above baseline (the "trending" signal), then by raw + // recent average as the tie-breaker (secondary stat). + rows.sort((a, b) => (b.delta - a.delta) || (b.recentAvg - a.recentAvg)); + const limited = opts.limit && opts.limit > 0 ? rows.slice(0, opts.limit) : rows; + return limited.map((r, i) => ({ rank: i + 1, ...r })); +} + +module.exports = { + computeHotList, + resolveStat, + __internals: { HOT_STATS, DEFAULT_STAT, mean }, +}; diff --git a/src/services/rosterLogs.js b/src/services/rosterLogs.js new file mode 100644 index 0000000..2d2685f --- /dev/null +++ b/src/services/rosterLogs.js @@ -0,0 +1,95 @@ +/** + * Roster game-log loader (Session 23). + * + * Streaks and hot lists both need "every player's recent game log" — but + * VYNDR caches logs per-player on demand (`gamelogs:{sport}:{player}:{n}`) + * as the grading flow touches them. There's no roster-wide pull, and we + * will NOT add API calls to build one (free/cheap-only session). + * + * So we read what's ALREADY cached: + * 1. A precomputed roster blob `rosterlogs:{sport}` if a prefetch wrote + * one (fast path — a single read). + * 2. Otherwise SCAN the per-player `gamelogs:{sport}:*` keys and assemble + * a roster from whatever's warm. + * + * Everything here is Redis-only (free) and defensive — any failure yields + * an empty roster, never a throw. An empty roster is a valid state: the + * streaks/hot-list panels simply render nothing while other layers carry + * the slate. + */ + +const { cacheGet, getRedisClient, isDegraded } = require('../utils/redis'); + +const SCAN_COUNT = 200; +const MAX_KEYS = 600; // safety cap so a huge cache can't stall a request + +/** + * Parse a player display name out of a gamelogs key. + * Key shape: `gamelogs:{sport}:{playerName}:{count}` — playerName may + * itself contain colons in theory, so split off the known head/tail. + */ +function playerFromKey(key, sport) { + const prefix = `gamelogs:${sport}:`; + if (!key.startsWith(prefix)) return null; + const rest = key.slice(prefix.length); + const lastColon = rest.lastIndexOf(':'); + if (lastColon === -1) return rest; + return rest.slice(0, lastColon); +} + +async function scanGameLogKeys(sport) { + if (isDegraded && isDegraded()) return []; + const redis = getRedisClient(); + if (!redis || typeof redis.scan !== 'function') return []; + const match = `gamelogs:${sport}:*`; + const keys = []; + let cursor = '0'; + try { + do { + const [next, batch] = await redis.scan(cursor, 'MATCH', match, 'COUNT', SCAN_COUNT); + cursor = next; + for (const k of batch) { + if (!keys.includes(k)) keys.push(k); + if (keys.length >= MAX_KEYS) return keys; + } + } while (cursor !== '0'); + } catch (err) { + console.warn('[rosterLogs] scan failed:', err.message); + return keys; + } + return keys; +} + +/** + * Returns [{ name, playerId, team, games }] for a sport. Dedupes players + * (the highest game-count key wins) so one player isn't double-counted + * across `:10` / `:20` cache variants. + */ +async function loadRosterLogs(sport) { + const key = String(sport || '').toLowerCase(); + if (!key) return []; + + // Fast path — a prefetched roster blob. + const blob = await cacheGet(`rosterlogs:${key}`); + if (Array.isArray(blob) && blob.length > 0) return blob; + + const keys = await scanGameLogKeys(key); + if (keys.length === 0) return []; + + const byPlayer = new Map(); + for (const k of keys) { + const name = playerFromKey(k, key); + if (!name) continue; + const games = await cacheGet(k); + if (!Array.isArray(games) || games.length === 0) continue; + const existing = byPlayer.get(name); + if (!existing || games.length > existing.games.length) { + const playerId = games[0]?.playerId ?? games[0]?.player_id ?? null; + const team = games[0]?.team ?? games[0]?.teamAbv ?? null; + byPlayer.set(name, { name, playerId, team, games }); + } + } + return Array.from(byPlayer.values()); +} + +module.exports = { loadRosterLogs, __internals: { playerFromKey, scanGameLogKeys } }; diff --git a/src/services/scheduleService.js b/src/services/scheduleService.js new file mode 100644 index 0000000..a759a83 --- /dev/null +++ b/src/services/scheduleService.js @@ -0,0 +1,182 @@ +/** + * Schedule service (Session 23). + * + * Today's game schedule from FREE ESPN scoreboards. NO odds-api credits + * burned. Cache-aside: reads `schedule:{sport}:{date}` from Redis first; + * on a miss it fetches the ESPN scoreboard directly (the same free + * endpoint the PM2 pollers hit every 60s), normalizes, caches, returns. + * + * This dual path is deliberate. The pollers warm the cache during game + * hours, but the endpoint must NEVER be empty just because a poller is + * down or off-hours — so it self-heals by fetching ESPN on a cache miss. + * + * Everything here is free. The only paid/quota'd layer (Tank01 game + * lines, odds-api props) is checked separately via the hasGameLines / + * hasOdds flags, which read OTHER caches without ever triggering a fetch. + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../utils/redis'); +const { SPORT_CONFIG } = require('../config/sports'); + +function getSportConfig(sport) { + return SPORT_CONFIG[String(sport || '').toLowerCase()] || null; +} + +const HTTP_TIMEOUT_MS = 10_000; +const SCHEDULE_TTL = 60; // 60s — mirrors poller cadence; live scores stay fresh +const STALE_TTL = 6 * 3600; // stale-while-error fallback + +/** + * Today's date in ET as YYYY-MM-DD. Sports days roll over on ET, not UTC, + * so a late west-coast game still counts as "today" past midnight UTC. + */ +function todayET() { + const fmt = new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', + year: 'numeric', month: '2-digit', day: '2-digit', + }); + return fmt.format(new Date()); // en-CA → YYYY-MM-DD +} + +/** + * Normalize one ESPN scoreboard event into VYNDR's schedule shape. + * Defensive throughout — ESPN omits fields freely (no venue for neutral + * sites, no broadcast until close to tip). A missing field becomes null, + * never a throw. + */ +function normalizeEvent(ev) { + if (!ev) return null; + const comp = ev.competitions?.[0] || {}; + const competitors = comp.competitors || []; + const home = competitors.find((c) => c.homeAway === 'home') || competitors[0] || {}; + const away = competitors.find((c) => c.homeAway === 'away') || competitors[1] || {}; + + const team = (c) => ({ + name: c?.team?.displayName || c?.team?.name || c?.team?.shortDisplayName || null, + abbreviation: c?.team?.abbreviation || null, + }); + + const score = (c) => { + const n = Number(c?.score); + return Number.isFinite(n) ? n : 0; + }; + + const state = ev.status?.type?.state || comp.status?.type?.state || null; // pre|in|post + const hasScore = state === 'in' || state === 'post'; + + // Broadcast: ESPN scatters this across competitions[].broadcasts and + // geoBroadcasts. Take the first network name we can find. + let broadcast = null; + const bcasts = comp.broadcasts || []; + if (bcasts[0]?.names?.[0]) broadcast = bcasts[0].names[0]; + else if (comp.geoBroadcasts?.[0]?.media?.shortName) broadcast = comp.geoBroadcasts[0].media.shortName; + + return { + id: String(ev.id), + homeTeam: team(home), + awayTeam: team(away), + gameTime: ev.date || comp.date || null, + status: state, + score: hasScore ? { home: score(home), away: score(away) } : null, + venue: comp.venue?.fullName || null, + broadcast, + hasOdds: false, // filled by enrichFlags + hasGameLines: false, // filled by enrichFlags + }; +} + +/** + * Fetch + normalize the ESPN scoreboard for a sport. Free endpoint. + */ +async function fetchScheduleFromEspn(sport) { + const cfg = getSportConfig(sport); + if (!cfg || !cfg.espnScoreboard) return null; + const res = await axios.get(cfg.espnScoreboard, { timeout: HTTP_TIMEOUT_MS }); + const events = res.data?.events || []; + return events.map(normalizeEvent).filter(Boolean); +} + +/** + * Cache-aside schedule read. Returns an array of normalized games + * (possibly empty — empty is a valid "no games today", not an error). + * Returns null only when the sport is unknown / unsupported. + */ +async function getSchedule(sport, date) { + const cfg = getSportConfig(sport); + if (!cfg || !cfg.espnScoreboard) return null; + const key = `schedule:${sport}:${date}`; + + const cached = await cacheGet(key); + if (cached !== null) return cached; + + try { + const games = await fetchScheduleFromEspn(sport); + if (Array.isArray(games)) { + await cacheSet(key, games, SCHEDULE_TTL); + await cacheSet(`${key}:stale`, games, STALE_TTL); + return games; + } + return []; + } catch (err) { + console.warn(`[schedule] ESPN fetch failed for ${sport}:`, err.message); + const stale = await cacheGet(`${key}:stale`); + return stale !== null ? stale : []; + } +} + +/** + * Per-game enrichment: set hasOdds / hasGameLines by peeking at the OTHER + * caches. Reads only — never triggers a fetch, never burns quota. The + * odds-api props cache and the Tank01 game-lines cache are date-keyed + * (one blob per sport+date), so a single read tells us whether ANY game + * that day has data; we apply it to every game in the slate. + * + * A future refinement could match per-game, but the date-level flag is + * the honest signal today: "props exist for this slate" / "lines exist + * for this slate". + */ +async function enrichFlags(sport, date, games) { + if (!Array.isArray(games) || games.length === 0) return games; + const ymd = String(date).replace(/-/g, ''); + + // odds-api props cache — oddsService writes `odds:{sport}:{utcDate}` + // as `{ updated_at, props, spreads }`. The slate `date` is ET, so try + // the ET key first then the UTC key (they differ only past midnight). + const utcDate = new Date().toISOString().split('T')[0]; + const oddsCache = + (await cacheGet(`odds:${sport}:${date}`)) ?? + (await cacheGet(`odds:${sport}:${utcDate}`)) ?? + (await cacheGet(`odds:${sport}`)); + const hasOdds = hasPropsData(oddsCache); + + // Tank01 game-lines cache — adapters write tank01:{sport}:odds:{ymd}. + const linesCache = await cacheGet(`tank01:${sport}:odds:${ymd}`); + const hasGameLines = hasLinesData(linesCache); + + return games.map((g) => ({ ...g, hasOdds, hasGameLines })); +} + +function hasPropsData(cache) { + if (!cache) return false; + if (Array.isArray(cache)) return cache.length > 0; + if (Array.isArray(cache.props)) return cache.props.length > 0; + if (Array.isArray(cache.games)) return cache.games.length > 0; + if (typeof cache === 'object') return Object.keys(cache).length > 0; + return false; +} + +function hasLinesData(cache) { + if (!cache) return false; + const body = cache.body || cache; + if (Array.isArray(body)) return body.length > 0; + if (typeof body === 'object') return Object.keys(body).length > 0; + return false; +} + +module.exports = { + getSchedule, + enrichFlags, + todayET, + __internals: { normalizeEvent, fetchScheduleFromEspn, hasPropsData, hasLinesData }, +}; diff --git a/src/services/streaksService.js b/src/services/streaksService.js new file mode 100644 index 0000000..2ee558e --- /dev/null +++ b/src/services/streaksService.js @@ -0,0 +1,253 @@ +/** + * Streaks engine (Session 23). + * + * Computes player streaks from cached game-log data. Everything analyzed + * through VYNDR's lens — not "Wemby 31 PPG" but "Wemby on a 4-game 28+ + * scoring streak." A streak is a CONSECUTIVE run of recent games meeting + * a threshold; we count from the most recent game backward and stop at + * the first miss. + * + * Pure & deterministic. `computePlayerStreaks` operates on one player's + * game array; `computeStreaks` fans out across a roster and returns a + * flat, sorted, optionally stat-filtered list. NO API calls live here — + * the route layer supplies cached logs. + * + * Game logs are expected MOST-RECENT-FIRST (index 0 = latest). Pass + * `{ chronological: true }` to reverse oldest-first input. + */ + +// ---- defensive numeric field reader ------------------------------------- +function num(row, ...keys) { + if (!row) return 0; + for (const k of keys) { + if (row[k] !== undefined && row[k] !== null && row[k] !== '') { + const n = Number(row[k]); + if (Number.isFinite(n)) return n; + } + } + return 0; +} + +// NBA/WNBA stat accessors — tolerate the several field spellings the +// Python stats service and Tank01 use. +const nba = { + points: (r) => num(r, 'points', 'pts', 'PTS'), + rebounds: (r) => num(r, 'rebounds', 'reb', 'REB', 'totReb'), + assists: (r) => num(r, 'assists', 'ast', 'AST'), + threes: (r) => num(r, 'threes', 'threes_made', 'fg3m', 'tptfgm', 'threePointersMade'), + blocks: (r) => num(r, 'blocks', 'blk', 'BLK'), + steals: (r) => num(r, 'steals', 'stl', 'STL'), + fgPct: (r) => { + const pct = num(r, 'fg_pct', 'fgPct', 'fieldGoalPct'); + if (pct > 0) return pct > 1 ? pct / 100 : pct; // accept 0–1 or 0–100 + const m = num(r, 'fgm', 'field_goals_made'); + const a = num(r, 'fga', 'field_goals_attempted'); + return a > 0 ? m / a : 0; + }, +}; +nba.pra = (r) => nba.points(r) + nba.rebounds(r) + nba.assists(r); +nba.doubleCount = (r) => + [nba.points(r), nba.rebounds(r), nba.assists(r), nba.steals(r), nba.blocks(r)] + .filter((v) => v >= 10).length; + +const mlb = { + hits: (r) => num(r, 'hits', 'H', 'h'), + homeRuns: (r) => num(r, 'homeRuns', 'home_runs', 'HR', 'hr'), + stolenBases: (r) => num(r, 'stolenBases', 'stolen_bases', 'SB', 'sb'), + rbi: (r) => num(r, 'rbi', 'RBI'), + walks: (r) => num(r, 'walks', 'baseOnBalls', 'BB', 'bb'), + hbp: (r) => num(r, 'hitByPitch', 'hbp', 'HBP'), + totalBases: (r) => num(r, 'totalBases', 'total_bases', 'TB'), + strikeouts: (r) => num(r, 'strikeOuts', 'strikeouts', 'pitcherK', 'K', 'so'), + inningsPitched: (r) => num(r, 'inningsPitched', 'ip', 'IP'), + earnedRuns: (r) => num(r, 'earnedRuns', 'er', 'ER'), +}; +mlb.onBase = (r) => mlb.hits(r) + mlb.walks(r) + mlb.hbp(r); +mlb.isQualityStart = (r) => mlb.inningsPitched(r) >= 6 && mlb.earnedRuns(r) <= 3; + +const nfl = { + passTd: (r) => num(r, 'passTD', 'passing_touchdowns', 'pass_td'), + rushTd: (r) => num(r, 'rushTD', 'rushing_touchdowns', 'rush_td'), + recTd: (r) => num(r, 'recTD', 'receiving_touchdowns', 'rec_td'), + rushYds:(r) => num(r, 'rushYds', 'rushing_yards', 'rush_yards'), + recYds: (r) => num(r, 'recYds', 'receiving_yards', 'rec_yards'), + ints: (r) => num(r, 'interceptions', 'int', 'passInt'), +}; +nfl.anyTd = (r) => nfl.passTd(r) + nfl.rushTd(r) + nfl.recTd(r); + +const soccer = { + goals: (r) => num(r, 'goals', 'G'), + assists: (r) => num(r, 'assists', 'A'), + shotsOnTarget: (r) => num(r, 'shotsOnTarget', 'shots_on_target', 'sot'), + goalsConceded: (r) => num(r, 'goalsConceded', 'goals_conceded', 'ga'), + minutes: (r) => num(r, 'minutes', 'min', 'MIN'), +}; + +// ---- streak specs ------------------------------------------------------- +// Each spec: { key, category, threshold, label, value, mode }. +// value(row) → number; the game counts toward the streak when value >= threshold. +// mode 'consecutive' (default) counts the run from the latest game. +// mode 'rate' marks "hot" when the mean over the last `window` games >= threshold. +const SPECS = { + nba: [ + { key: 'points_25', category: 'points', collapse: 'points', threshold: 25, label: '25+ pts', value: nba.points }, + { key: 'points_20', category: 'points', collapse: 'points', threshold: 20, label: '20+ pts', value: nba.points }, + { key: 'assists_8', category: 'assists', collapse: 'assists', threshold: 8, label: '8+ ast', value: nba.assists }, + { key: 'assists_6', category: 'assists', collapse: 'assists', threshold: 6, label: '6+ ast', value: nba.assists }, + { key: 'rebounds_10',category: 'rebounds', collapse: 'rebounds', threshold: 10, label: '10+ reb', value: nba.rebounds }, + { key: 'rebounds_8', category: 'rebounds', collapse: 'rebounds', threshold: 8, label: '8+ reb', value: nba.rebounds }, + { key: 'threes_4', category: 'threes', collapse: 'threes', threshold: 4, label: '4+ threes', value: nba.threes }, + { key: 'threes_3', category: 'threes', collapse: 'threes', threshold: 3, label: '3+ threes', value: nba.threes }, + { key: 'blocks_2', category: 'blocks', threshold: 2, label: '2+ blk', value: nba.blocks }, + { key: 'steals_2', category: 'steals', threshold: 2, label: '2+ stl', value: nba.steals }, + { key: 'pra_40', category: 'pra', threshold: 40, label: '40+ PRA', value: nba.pra }, + { key: 'double_double', category: 'all', threshold: 2, label: 'double-double', value: nba.doubleCount, noun: 'double-double' }, + { key: 'triple_double', category: 'all', threshold: 3, label: 'triple-double', value: nba.doubleCount, noun: 'triple-double' }, + { key: 'hot_shooter', category: 'points', threshold: 0.5, label: 'hot shooter (FG% > 50%)', value: nba.fgPct, mode: 'rate', window: 5 }, + ], + // WNBA shares NBA's stat layout (no PRA/triple-double headline emphasis, + // but the specs are harmless if a player never hits them). + wnba: null, // filled below = nba minus the rate spec quirks + mlb: [ + { key: 'hit_streak', category: 'hits', collapse: 'hits', threshold: 1, label: 'hit', value: mlb.hits }, + { key: 'multi_hit', category: 'hits', collapse: 'hits', threshold: 2, label: 'multi-hit', value: mlb.hits }, + { key: 'hr_streak', category: 'home_runs', threshold: 1, label: 'HR', value: mlb.homeRuns }, + { key: 'sb_streak', category: 'stolen_bases', threshold: 1, label: 'SB', value: mlb.stolenBases }, + { key: 'rbi_streak', category: 'rbis', threshold: 1, label: 'RBI', value: mlb.rbi }, + { key: 'onbase_streak',category: 'on_base', threshold: 1, label: 'on-base', value: mlb.onBase }, + { key: 'tb_streak', category: 'total_bases', threshold: 2, label: '2+ total bases', value: mlb.totalBases }, + { key: 'k_streak', category: 'strikeouts', threshold: 7, label: '7+ K', value: mlb.strikeouts }, + { key: 'qs_streak', category: 'strikeouts', threshold: 1, label: 'quality start', value: (r) => (mlb.isQualityStart(r) ? 1 : 0) }, + ], + nfl: [ + { key: 'td_streak', category: 'touchdowns', threshold: 1, label: 'TD', value: nfl.anyTd }, + { key: 'multi_td', category: 'touchdowns', threshold: 2, label: 'multi-TD', value: nfl.anyTd }, + { key: 'rush_100', category: 'rushing_yards', threshold: 100, label: '100-yd rushing', value: nfl.rushYds }, + { key: 'rec_100', category: 'receiving_yards', threshold: 100, label: '100-yd receiving', value: nfl.recYds }, + { key: 'clean_qb', category: 'interceptions', threshold: 1, label: 'INT-free', value: (r) => (nfl.ints(r) === 0 ? 1 : 0) }, + ], + soccer: [ + { key: 'goal_streak', category: 'goals', threshold: 1, label: 'goal', value: soccer.goals }, + { key: 'assist_streak', category: 'assists', threshold: 1, label: 'assist', value: soccer.assists }, + { key: 'sot_streak', category: 'shots', threshold: 1, label: 'shot-on-target', value: soccer.shotsOnTarget }, + { key: 'clean_sheet', category: 'saves', threshold: 1, label: 'clean sheet', + value: (r) => (soccer.minutes(r) > 0 && soccer.goalsConceded(r) === 0 ? 1 : 0) }, + ], +}; +SPECS.wnba = SPECS.nba; + +function specsFor(sport) { + return SPECS[String(sport || '').toLowerCase()] || []; +} + +// ---- core streak math --------------------------------------------------- +function consecutiveRun(games, valueFn, threshold) { + let run = 0; + for (const g of games) { + if (valueFn(g) >= threshold) run += 1; + else break; + } + return run; +} + +function rateOverWindow(games, valueFn, window) { + const slice = games.slice(0, window); + if (slice.length < window) return { value: 0, count: slice.length }; + const sum = slice.reduce((acc, g) => acc + valueFn(g), 0); + return { value: sum / slice.length, count: slice.length }; +} + +/** + * Minimum run length to surface a streak. A "1-game streak" is just a + * stat line, not a streak — require at least 2 to count as VYNDR signal, + * except double/triple-double which are notable at any length >= 2. + */ +const MIN_STREAK = 2; + +function describe(spec, run) { + if (spec.noun) return `${run}-game ${spec.noun} streak`; + if (spec.mode === 'rate') return spec.label; + return `${run}-game ${spec.label} streak`; +} + +/** + * All streaks for ONE player. Returns the strongest streak per stat + * CATEGORY (so a player with a 20+ and a 25+ points streak surfaces only + * the more impressive one) — keeps the feed signal-dense. + */ +function computePlayerStreaks(player, sport, opts = {}) { + const specs = specsFor(sport); + let games = Array.isArray(player?.games) ? player.games.slice() : []; + if (opts.chronological) games.reverse(); + if (games.length === 0) return []; + + const found = []; + for (const spec of specs) { + if (spec.mode === 'rate') { + const { value, count } = rateOverWindow(games, spec.value, spec.window); + if (count >= spec.window && value >= spec.threshold) { + found.push(makeStreak(player, sport, spec, spec.window, value)); + } + continue; + } + const run = consecutiveRun(games, spec.value, spec.threshold); + if (run >= MIN_STREAK) found.push(makeStreak(player, sport, spec, run)); + } + + // Collapse tiered specs (e.g. 25+ and 20+ points) to one entry per + // collapse group — prefer the MORE IMPRESSIVE streak (higher threshold), + // tie-broken by the longer run. Non-tiered specs each have a unique + // collapse key, so they pass through untouched. + const best = new Map(); + for (const s of found) { + const cur = best.get(s._collapse); + if (!cur || + s.threshold > cur.threshold || + (s.threshold === cur.threshold && s.currentStreak > cur.currentStreak)) { + best.set(s._collapse, s); + } + } + return Array.from(best.values()).map(({ _collapse, ...rest }) => rest); +} + +function makeStreak(player, sport, spec, run, rateValue) { + return { + sport, + player: player.name || player.player || null, + playerId: player.playerId ?? player.id ?? null, + team: player.team || null, + type: spec.key, + category: spec.category, + threshold: spec.threshold, + currentStreak: run, + rate: rateValue ?? null, + description: describe(spec, run), + active: true, + _collapse: spec.collapse || spec.key, // internal — stripped before return + }; +} + +/** + * Fan out across a roster. `players` = [{ name, playerId, team, games }]. + * Returns a flat list sorted by streak length desc, optionally narrowed + * to a single stat category and capped at `limit`. + */ +function computeStreaks(players, sport, opts = {}) { + if (!Array.isArray(players)) return []; + const stat = opts.stat && opts.stat !== 'all' ? String(opts.stat).toLowerCase() : null; + let all = []; + for (const p of players) { + all = all.concat(computePlayerStreaks(p, sport, opts)); + } + if (stat) all = all.filter((s) => s.category === stat); + all.sort((a, b) => b.currentStreak - a.currentStreak); + if (opts.limit && opts.limit > 0) all = all.slice(0, opts.limit); + return all; +} + +module.exports = { + computeStreaks, + computePlayerStreaks, + specsFor, + __internals: { consecutiveRun, rateOverWindow, nba, mlb, nfl, soccer, MIN_STREAK }, +}; diff --git a/tests/integration/gameLinesRoute.test.js b/tests/integration/gameLinesRoute.test.js new file mode 100644 index 0000000..8664f4a --- /dev/null +++ b/tests/integration/gameLinesRoute.test.js @@ -0,0 +1,98 @@ +// Integration: /api/gamelines/:sport (Session 23). +// +// The Tank01 adapters are mocked — we assert the route normalizes the +// book-by-book body, parses teams from the gameID, handles the missing +// API-key path gracefully, and never 500s on adapter failure. + +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../src/services/adapters/tank01NbaAdapter', () => ({ + getNBABettingOdds: jest.fn(), + hasApiKey: jest.fn(() => true), +})); +jest.mock('../../src/services/adapters/tank01MlbAdapter', () => ({ + getMLBBettingOdds: jest.fn(), + hasApiKey: jest.fn(() => true), +})); +jest.mock('../../src/services/scheduleService', () => ({ + todayET: () => '2026-06-12', +})); + +const nbaAdapter = require('../../src/services/adapters/tank01NbaAdapter'); +const mlbAdapter = require('../../src/services/adapters/tank01MlbAdapter'); + +function mountApp() { + delete require.cache[require.resolve('../../src/routes/gameLines')]; + const routes = require('../../src/routes/gameLines'); + const app = express(); + app.use('/api/gamelines', routes); + return app; +} + +const MLB_BODY = { + '20260612_ARI@CIN': { + gameID: '20260612_ARI@CIN', + sportsBooks: [ + { sportsBook: 'bet365', odds: { homeTeamMLOdds: '-110', awayTeamMLOdds: '+100', totalOver: '9.5', totalOverOdds: '-105', totalUnderOdds: '-115' } }, + { sportsBook: 'betmgm', odds: { homeTeamMLOdds: '-115', awayTeamMLOdds: '-105', totalOver: '9' } }, + ], + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + // clearAllMocks wipes call history but not custom implementations set via + // mockReturnValue in a prior test — restore the configured-key default. + nbaAdapter.hasApiKey.mockReturnValue(true); + mlbAdapter.hasApiKey.mockReturnValue(true); +}); + +describe('GET /api/gamelines/:sport', () => { + test('mlb returns book-by-book odds with teams parsed from gameID', async () => { + mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY); + const res = await request(mountApp()).get('/api/gamelines/mlb'); + expect(res.status).toBe(200); + expect(res.body.source).toBe('tank01'); + const game = res.body.games['20260612_ARI@CIN']; + expect(game.homeTeam).toBe('CIN'); + expect(game.awayTeam).toBe('ARI'); + expect(game.books.bet365.homeML).toBe('-110'); + expect(game.books.bet365.total).toBe('9.5'); + expect(game.books.betmgm.homeML).toBe('-115'); + }); + + test('nba returns empty games object off-season (not an error)', async () => { + nbaAdapter.getNBABettingOdds.mockResolvedValue({}); + const res = await request(mountApp()).get('/api/gamelines/nba'); + expect(res.status).toBe(200); + expect(res.body.games).toEqual({}); + }); + + test('missing RAPID_API_KEY → graceful, configured:false, not a crash', async () => { + mlbAdapter.hasApiKey.mockReturnValue(false); + const res = await request(mountApp()).get('/api/gamelines/mlb'); + expect(res.status).toBe(200); + expect(res.body.configured).toBe(false); + expect(mlbAdapter.getMLBBettingOdds).not.toHaveBeenCalled(); + }); + + test('adapter throwing → empty games, 200 not 500', async () => { + mlbAdapter.getMLBBettingOdds.mockRejectedValue(new Error('rapidapi 429')); + const res = await request(mountApp()).get('/api/gamelines/mlb'); + expect(res.status).toBe(200); + expect(res.body.games).toEqual({}); + }); + + test('unsupported sport → 404', async () => { + const res = await request(mountApp()).get('/api/gamelines/soccer'); + expect(res.status).toBe(404); + }); + + test('cache works — adapter called once per request (adapter owns TTL)', async () => { + mlbAdapter.getMLBBettingOdds.mockResolvedValue(MLB_BODY); + const app = mountApp(); + await request(app).get('/api/gamelines/mlb'); + expect(mlbAdapter.getMLBBettingOdds).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/integration/scheduleRoute.test.js b/tests/integration/scheduleRoute.test.js new file mode 100644 index 0000000..e14f5ce --- /dev/null +++ b/tests/integration/scheduleRoute.test.js @@ -0,0 +1,127 @@ +// Integration: /api/schedule/:sport (Session 23). +// +// The schedule service is mocked at the redis layer so we exercise the +// route + service normalization + flag enrichment without hitting ESPN +// or a live Redis. ESPN itself is stubbed via axios. + +const express = require('express'); +const request = require('supertest'); + +jest.mock('axios'); +const axios = require('axios'); + +// In-memory redis stand-in. cacheGet returns whatever we seed; cacheSet +// records writes so we can assert the cache-aside warm path. +const store = {}; +jest.mock('../../src/utils/redis', () => ({ + cacheGet: jest.fn(async (k) => (k in store ? store[k] : null)), + cacheSet: jest.fn(async (k, v) => { store[k] = v; return true; }), +})); + +function mountApp() { + delete require.cache[require.resolve('../../src/routes/schedule')]; + delete require.cache[require.resolve('../../src/services/scheduleService')]; + const scheduleRoutes = require('../../src/routes/schedule'); + const app = express(); + app.use(express.json()); + app.use('/api/schedule', scheduleRoutes); + return app; +} + +const ESPN_NBA = { + data: { + events: [ + { + id: '401234567', + date: '2026-06-14T00:40:00Z', + status: { type: { state: 'pre' } }, + competitions: [{ + venue: { fullName: 'Frost Bank Center' }, + broadcasts: [{ names: ['ABC'] }], + competitors: [ + { homeAway: 'home', score: '0', team: { displayName: 'San Antonio Spurs', abbreviation: 'SA' } }, + { homeAway: 'away', score: '0', team: { displayName: 'New York Knicks', abbreviation: 'NYK' } }, + ], + }], + }, + ], + }, +}; + +beforeEach(() => { + for (const k of Object.keys(store)) delete store[k]; + jest.clearAllMocks(); +}); + +describe('GET /api/schedule/:sport', () => { + test('returns normalized games from a fresh ESPN fetch (cache miss)', async () => { + axios.get.mockResolvedValue(ESPN_NBA); + const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12'); + expect(res.status).toBe(200); + expect(res.body.sport).toBe('nba'); + expect(res.body.source).toBe('espn'); + expect(res.body.games).toHaveLength(1); + const g = res.body.games[0]; + expect(g.homeTeam.abbreviation).toBe('SA'); + expect(g.awayTeam.name).toBe('New York Knicks'); + expect(g.status).toBe('pre'); + expect(g.venue).toBe('Frost Bank Center'); + expect(g.broadcast).toBe('ABC'); + expect(g.score).toBeNull(); // pre-game → no score + expect(g.hasOdds).toBe(false); + expect(g.hasGameLines).toBe(false); + }); + + test('warms the cache, second call does not re-fetch ESPN', async () => { + axios.get.mockResolvedValue(ESPN_NBA); + const app = mountApp(); + await request(app).get('/api/schedule/nba?date=2026-06-12'); + const callsAfterFirst = axios.get.mock.calls.length; + await request(app).get('/api/schedule/nba?date=2026-06-12'); + expect(axios.get.mock.calls.length).toBe(callsAfterFirst); // served from cache + }); + + test('returns empty array (not error) when no games today', async () => { + axios.get.mockResolvedValue({ data: { events: [] } }); + const res = await request(mountApp()).get('/api/schedule/mlb?date=2026-06-12'); + expect(res.status).toBe(200); + expect(res.body.games).toEqual([]); + }); + + test('hasOdds true when odds-api cache has props for the slate', async () => { + store['odds:nba:2026-06-12'] = { updated_at: 'x', props: [{ player: 'Wemby' }] }; + axios.get.mockResolvedValue(ESPN_NBA); + const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12'); + expect(res.body.games[0].hasOdds).toBe(true); + expect(res.body.games[0].hasGameLines).toBe(false); + }); + + test('hasGameLines true when Tank01 odds cache has data', async () => { + store['tank01:nba:odds:20260612'] = { body: { '20260612_NYK@SA': { homeML: '-110' } } }; + axios.get.mockResolvedValue(ESPN_NBA); + const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12'); + expect(res.body.games[0].hasGameLines).toBe(true); + }); + + test('scores appear once a game is in/post', async () => { + axios.get.mockResolvedValue({ + data: { events: [{ + id: '9', date: '2026-06-12T20:00:00Z', + status: { type: { state: 'in' } }, + competitions: [{ + competitors: [ + { homeAway: 'home', score: '54', team: { displayName: 'A', abbreviation: 'A' } }, + { homeAway: 'away', score: '49', team: { displayName: 'B', abbreviation: 'B' } }, + ], + }], + }] }, + }); + const res = await request(mountApp()).get('/api/schedule/nba?date=2026-06-12'); + expect(res.body.games[0].score).toEqual({ home: 54, away: 49 }); + }); + + test('unknown sport returns 404', async () => { + const res = await request(mountApp()).get('/api/schedule/cricket'); + expect(res.status).toBe(404); + }); +}); diff --git a/tests/integration/streaksHotlistRoutes.test.js b/tests/integration/streaksHotlistRoutes.test.js new file mode 100644 index 0000000..279166c --- /dev/null +++ b/tests/integration/streaksHotlistRoutes.test.js @@ -0,0 +1,90 @@ +// Integration: /api/streaks/:sport and /api/hotlist/:sport (Session 23). +// rosterLogs is mocked so we exercise route → engine without Redis. + +const express = require('express'); +const request = require('supertest'); + +jest.mock('../../src/services/rosterLogs', () => ({ + loadRosterLogs: jest.fn(), +})); +const { loadRosterLogs } = require('../../src/services/rosterLogs'); + +function mountStreaks() { + delete require.cache[require.resolve('../../src/routes/streaks')]; + const app = express(); + app.use('/api/streaks', require('../../src/routes/streaks')); + return app; +} +function mountHotlist() { + delete require.cache[require.resolve('../../src/routes/hotlist')]; + const app = express(); + app.use('/api/hotlist', require('../../src/routes/hotlist')); + return app; +} + +beforeEach(() => jest.clearAllMocks()); + +describe('GET /api/streaks/:sport', () => { + test('returns computed streaks from cached logs', async () => { + loadRosterLogs.mockResolvedValue([ + { name: 'Wemby', team: 'SA', games: [{ points: 30 }, { points: 28 }, { points: 26 }] }, + ]); + const res = await request(mountStreaks()).get('/api/streaks/nba'); + expect(res.status).toBe(200); + expect(res.body.source).toBe('computed'); + expect(res.body.streaks[0].player).toBe('Wemby'); + expect(res.body.streaks[0].currentStreak).toBe(3); + }); + + test('stat filter narrows the response', async () => { + loadRosterLogs.mockResolvedValue([ + { name: 'A', games: [{ points: 30 }, { points: 30 }] }, + { name: 'B', games: [{ assists: 9 }, { assists: 8 }] }, + ]); + const res = await request(mountStreaks()).get('/api/streaks/nba?stat=points'); + expect(res.body.stat).toBe('points'); + expect(res.body.streaks.every((s) => s.category === 'points')).toBe(true); + }); + + test('empty roster → empty streaks, not an error', async () => { + loadRosterLogs.mockResolvedValue([]); + const res = await request(mountStreaks()).get('/api/streaks/mlb'); + expect(res.status).toBe(200); + expect(res.body.streaks).toEqual([]); + }); + + test('loader throwing → 200 with empty streaks (platform never down)', async () => { + loadRosterLogs.mockRejectedValue(new Error('redis exploded')); + const res = await request(mountStreaks()).get('/api/streaks/nba'); + expect(res.status).toBe(200); + expect(res.body.streaks).toEqual([]); + }); + + test('unsupported sport → 404', async () => { + const res = await request(mountStreaks()).get('/api/streaks/cricket'); + expect(res.status).toBe(404); + }); +}); + +describe('GET /api/hotlist/:sport', () => { + test('returns ranked hot players', async () => { + loadRosterLogs.mockResolvedValue([ + { name: 'Riser', seasonAvg: { points: 18 }, games: [{ points: 28 }, { points: 30 }] }, + ]); + const res = await request(mountHotlist()).get('/api/hotlist/nba?stat=points'); + expect(res.status).toBe(200); + expect(res.body.players[0].name).toBe('Riser'); + expect(res.body.players[0].rank).toBe(1); + }); + + test('empty roster → empty players', async () => { + loadRosterLogs.mockResolvedValue([]); + const res = await request(mountHotlist()).get('/api/hotlist/mlb'); + expect(res.body.players).toEqual([]); + }); + + test('unsupported sport → 404', async () => { + const res = await request(mountHotlist()).get('/api/hotlist/nfl'); + expect(res.status).toBe(404); + }); +}); diff --git a/tests/unit/hotListService.test.js b/tests/unit/hotListService.test.js new file mode 100644 index 0000000..bbb8ef1 --- /dev/null +++ b/tests/unit/hotListService.test.js @@ -0,0 +1,82 @@ +// Unit: hot-list engine (Session 23). Pure function. + +const { computeHotList } = require('../../src/services/hotListService'); + +describe('hotListService', () => { + test('"hot" means above personal average, not just high raw numbers', () => { + const players = [ + // 20-PPG player erupting for 28/31/25 → HOT + { name: 'Riser', games: [{ points: 28 }, { points: 31 }, { points: 25 }, { points: 20 }, { points: 19 }, { points: 21 }, { points: 20 }, { points: 18 }, { points: 22 }, { points: 20 }] }, + // 30-PPG star who dropped 28 recently → NOT hot (below own baseline) + { name: 'Star', games: [{ points: 28 }, { points: 27 }, { points: 26 }, { points: 31 }, { points: 33 }, { points: 30 }, { points: 32 }, { points: 31 }, { points: 30 }, { points: 33 }] }, + ]; + const list = computeHotList(players, 'nba', { stat: 'points', window: 3 }); + expect(list.map((p) => p.name)).toContain('Riser'); + expect(list.map((p) => p.name)).not.toContain('Star'); + }); + + test('returns a ranked list with rank field', () => { + const players = [ + { name: 'A', games: [{ points: 40 }, { points: 40 }, { points: 10 }, { points: 10 }] }, + { name: 'B', games: [{ points: 22 }, { points: 22 }, { points: 18 }, { points: 18 }] }, + ]; + const list = computeHotList(players, 'nba', { stat: 'points', window: 2 }); + expect(list[0].rank).toBe(1); + expect(list[0].name).toBe('A'); // biggest jump above baseline + expect(list[0].delta).toBeGreaterThan(list[1].delta); + }); + + test('uses explicit seasonAvg as baseline when present', () => { + const players = [ + { name: 'C', seasonAvg: { points: 15 }, games: [{ points: 25 }, { points: 25 }] }, + ]; + const list = computeHotList(players, 'nba', { stat: 'points', window: 2 }); + expect(list[0].baseline).toBe(15); + expect(list[0].delta).toBe(10); + }); + + test('7-day window filters by date when dates + now supplied', () => { + const now = new Date('2026-06-12T12:00:00Z').getTime(); + const day = 86_400_000; + const players = [ + { name: 'D', games: [ + { date: '2026-06-11', hits: 3 }, // in window + { date: '2026-06-10', hits: 2 }, // in window + { date: '2026-06-01', hits: 0 }, // outside 7d → baseline + { date: '2026-05-30', hits: 0 }, + ] }, + ]; + const list = computeHotList(players, 'mlb', { stat: 'hits', windowDays: 7, now }); + expect(list).toHaveLength(1); + expect(list[0].window).toBe(2); // only the 2 recent games + expect(list[0].recentAvg).toBe(2.5); + }); + + test('player with no baseline to trend against is excluded', () => { + const players = [ + { name: 'E', games: [{ points: 30 }, { points: 30 }] }, // window 7 → no rest, no seasonAvg + ]; + const list = computeHotList(players, 'nba', { stat: 'points' }); + expect(list).toHaveLength(0); + }); + + test('tie on delta broken by raw recent average', () => { + const players = [ + { name: 'Low', games: [{ points: 15 }, { points: 15 }, { points: 10 }, { points: 10 }] }, // +5, recent 15 + { name: 'High', games: [{ points: 25 }, { points: 25 }, { points: 20 }, { points: 20 }] }, // +5, recent 25 + ]; + const list = computeHotList(players, 'nba', { stat: 'points', window: 2 }); + expect(list[0].name).toBe('High'); + }); + + test('empty input → empty list', () => { + expect(computeHotList([], 'nba')).toEqual([]); + expect(computeHotList(null, 'nba')).toEqual([]); + }); + + test('all/default stat resolves to the sport headline stat', () => { + const players = [{ name: 'F', games: [{ hits: 3 }, { hits: 3 }, { hits: 0 }, { hits: 0 }] }]; + const list = computeHotList(players, 'mlb', { stat: 'all', window: 2 }); + expect(list[0].stat).toBe('hits'); + }); +}); diff --git a/tests/unit/providersRegistry.test.js b/tests/unit/providersRegistry.test.js new file mode 100644 index 0000000..a69e862 --- /dev/null +++ b/tests/unit/providersRegistry.test.js @@ -0,0 +1,54 @@ +// Unit: provider registry dead-provider handling (Session 23). +// +// ParlayAPI was marked `status: 'dead'` after Chrome Claude confirmed its +// host no longer resolves. It must be excluded from every fallback chain +// and the configured-providers list, while still resolving via getProvider +// (the adapter + its mocked tests still reference the config). + +const { + getProvider, getFallbackChain, getConfiguredProviders, isDeadProvider, +} = require('../../src/config/providers'); + +describe('provider registry — dead providers', () => { + const saved = {}; + beforeAll(() => { + // Configure keys so the chain/list filters are exercised on presence. + for (const k of ['PARLAYAPI_KEY', 'ODDS_API_KEY', 'ODDSPAPI_KEY']) { + saved[k] = process.env[k]; + process.env[k] = 'test-key'; + } + }); + afterAll(() => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }); + + test('parlayapi is flagged dead', () => { + expect(isDeadProvider('parlayapi')).toBe(true); + expect(getProvider('parlayapi')).not.toBeNull(); // config still resolves + expect(getProvider('parlayapi').status).toBe('dead'); + }); + + test('dead provider is excluded from fallback chains', () => { + const chain = getFallbackChain('historical_props', 'nba', null); + expect(chain).not.toContain('parlayapi'); + }); + + test('dead provider is excluded from configured providers', () => { + const ids = getConfiguredProviders().map((p) => p.id); + expect(ids).not.toContain('parlayapi'); + }); + + test('live providers still appear in fallback chains', () => { + const chain = getFallbackChain('closing_lines', 'nba', null); + expect(chain).toContain('oddspapi'); + }); + + test('non-dead providers report isDeadProvider false', () => { + expect(isDeadProvider('odds-api')).toBe(false); + expect(isDeadProvider('tank01')).toBe(false); + expect(isDeadProvider('nonexistent')).toBe(false); + }); +}); diff --git a/tests/unit/rosterLogs.test.js b/tests/unit/rosterLogs.test.js new file mode 100644 index 0000000..a982d38 --- /dev/null +++ b/tests/unit/rosterLogs.test.js @@ -0,0 +1,60 @@ +// Unit: rosterLogs loader (Session 23). Redis-only; no network. + +const store = {}; +const mockScan = jest.fn(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: jest.fn(async (k) => (k in store ? store[k] : null)), + getRedisClient: () => ({ scan: mockScan }), + isDegraded: () => false, +})); + +const { loadRosterLogs, __internals } = require('../../src/services/rosterLogs'); + +beforeEach(() => { + for (const k of Object.keys(store)) delete store[k]; + mockScan.mockReset(); +}); + +describe('rosterLogs', () => { + test('fast path: returns a prefetched roster blob without scanning', async () => { + store['rosterlogs:nba'] = [{ name: 'Wemby', games: [{ points: 30 }] }]; + const roster = await loadRosterLogs('nba'); + expect(roster).toHaveLength(1); + expect(mockScan).not.toHaveBeenCalled(); + }); + + test('scan path: assembles roster from per-player gamelogs keys', async () => { + store['gamelogs:nba:Wembanyama:20'] = [{ points: 30, team: 'SA', playerId: 'W1' }]; + store['gamelogs:nba:Brunson:20'] = [{ points: 24, team: 'NYK' }]; + mockScan + .mockResolvedValueOnce(['0', ['gamelogs:nba:Wembanyama:20', 'gamelogs:nba:Brunson:20']]); + const roster = await loadRosterLogs('nba'); + expect(roster.map((p) => p.name).sort()).toEqual(['Brunson', 'Wembanyama']); + const wemby = roster.find((p) => p.name === 'Wembanyama'); + expect(wemby.team).toBe('SA'); + expect(wemby.playerId).toBe('W1'); + }); + + test('dedupes by highest game-count variant', async () => { + store['gamelogs:nba:Star:10'] = [{ points: 1 }, { points: 2 }]; + store['gamelogs:nba:Star:20'] = [{ points: 1 }, { points: 2 }, { points: 3 }]; + mockScan.mockResolvedValueOnce(['0', ['gamelogs:nba:Star:10', 'gamelogs:nba:Star:20']]); + const roster = await loadRosterLogs('nba'); + expect(roster).toHaveLength(1); + expect(roster[0].games).toHaveLength(3); // the :20 variant wins + }); + + test('empty cache → empty roster, never throws', async () => { + mockScan.mockResolvedValueOnce(['0', []]); + expect(await loadRosterLogs('nba')).toEqual([]); + }); + + test('scan failure → returns what it had, no throw', async () => { + mockScan.mockRejectedValueOnce(new Error('redis down')); + expect(await loadRosterLogs('nba')).toEqual([]); + }); + + test('playerFromKey parses the player name out of the key', () => { + expect(__internals.playerFromKey('gamelogs:nba:LeBron James:20', 'nba')).toBe('LeBron James'); + }); +}); diff --git a/tests/unit/statFilters.test.js b/tests/unit/statFilters.test.js new file mode 100644 index 0000000..de56e80 --- /dev/null +++ b/tests/unit/statFilters.test.js @@ -0,0 +1,51 @@ +// Unit: stat-filter config + /api/stats/filters/:sport (Session 23). + +const express = require('express'); +const request = require('supertest'); +const { STAT_FILTERS, getStatFilters, isValidStat } = require('../../src/config/statFilters'); + +describe('statFilters config', () => { + test('every sport list starts with "all"', () => { + for (const list of Object.values(STAT_FILTERS)) { + expect(list[0]).toBe('all'); + } + }); + + test('NBA categories include the headline stats', () => { + expect(getStatFilters('nba')).toEqual( + expect.arrayContaining(['points', 'rebounds', 'assists', 'threes', 'pra']), + ); + }); + + test('MLB categories include home_runs and total_bases', () => { + expect(getStatFilters('mlb')).toEqual( + expect.arrayContaining(['home_runs', 'total_bases', 'on_base']), + ); + }); + + test('unknown sport falls back to ["all"]', () => { + expect(getStatFilters('quidditch')).toEqual(['all']); + }); + + test('isValidStat — all is always valid, unknown stat is not', () => { + expect(isValidStat('nba', 'all')).toBe(true); + expect(isValidStat('nba', 'points')).toBe(true); + expect(isValidStat('nba', 'home_runs')).toBe(false); + expect(isValidStat('mlb', 'home_runs')).toBe(true); + }); +}); + +describe('GET /api/stats/filters/:sport', () => { + function app() { + const a = express(); + a.use('/api/stats', require('../../src/routes/stats')); + return a; + } + + test('returns the category list for a sport', async () => { + const res = await request(app()).get('/api/stats/filters/nba'); + expect(res.status).toBe(200); + expect(res.body.sport).toBe('nba'); + expect(res.body.filters).toContain('points'); + }); +}); diff --git a/tests/unit/streaksService.test.js b/tests/unit/streaksService.test.js new file mode 100644 index 0000000..1e8b87c --- /dev/null +++ b/tests/unit/streaksService.test.js @@ -0,0 +1,130 @@ +// Unit: streaks engine (Session 23). Pure function — no mocks needed. + +const { computeStreaks, computePlayerStreaks } = require('../../src/services/streaksService'); + +// Most-recent-first game logs. +function nbaGames(...rows) { return rows; } + +describe('streaksService — NBA', () => { + test('detects a consecutive 25+ points streak from the latest games', () => { + const player = { + name: 'Wembanyama', playerId: 'W1', team: 'SA', + games: [{ points: 31 }, { points: 28 }, { points: 33 }, { points: 22 }, { points: 40 }], + }; + const streaks = computePlayerStreaks(player, 'nba'); + const pts = streaks.find((s) => s.category === 'points'); + expect(pts).toBeDefined(); + // 31,28,33 meet 25+, then 22 breaks → run of 3. + expect(pts.currentStreak).toBe(3); + expect(pts.description).toBe('3-game 25+ pts streak'); + }); + + test('streak breaks immediately when the latest game misses the threshold', () => { + const player = { name: 'X', games: [{ points: 10 }, { points: 30 }, { points: 30 }] }; + const streaks = computePlayerStreaks(player, 'nba'); + expect(streaks.find((s) => s.category === 'points')).toBeUndefined(); // run of 0 < MIN_STREAK + }); + + test('only the strongest streak per category surfaces', () => { + // 20+ and 25+ both qualify; keep the 25+ (higher run not guaranteed, but + // one points entry only). + const player = { name: 'Y', games: [{ points: 26 }, { points: 27 }, { points: 21 }] }; + const streaks = computePlayerStreaks(player, 'nba'); + const ptsEntries = streaks.filter((s) => s.category === 'points'); + expect(ptsEntries).toHaveLength(1); + }); + + test('double-double streak counts categories >= 10', () => { + const player = { + name: 'Jokic', games: [ + { points: 20, rebounds: 12, assists: 11 }, + { points: 15, rebounds: 10, assists: 9 }, + { points: 8, rebounds: 11, assists: 3 }, + ], + }; + const streaks = computePlayerStreaks(player, 'nba'); + const dd = streaks.find((s) => s.type === 'double_double'); + expect(dd).toBeDefined(); + // g0: 3 doubles, g1: 2 doubles, g2: 1 double → dd (>=2) run breaks at g2. + expect(dd.currentStreak).toBe(2); + expect(dd.description).toBe('2-game double-double streak'); + }); + + test('empty game logs → empty streaks (not error)', () => { + expect(computePlayerStreaks({ name: 'Z', games: [] }, 'nba')).toEqual([]); + expect(computeStreaks([], 'nba')).toEqual([]); + }); + + test('chronological input is reversed before counting', () => { + const player = { name: 'C', games: [{ points: 22 }, { points: 30 }, { points: 31 }] }; + // As chronological (oldest first) the latest is 31,30 → run 2. + const streaks = computePlayerStreaks(player, 'nba', { chronological: true }); + const pts = streaks.find((s) => s.category === 'points'); + expect(pts.currentStreak).toBe(2); + }); +}); + +describe('streaksService — MLB', () => { + test('classic hit streak counts consecutive games with a hit', () => { + const player = { + name: 'Acuna', team: 'ATL', + games: [{ hits: 2 }, { hits: 1 }, { hits: 3 }, { hits: 0 }, { hits: 1 }], + }; + const streaks = computePlayerStreaks(player, 'mlb'); + const hit = streaks.find((s) => s.type === 'hit_streak'); + expect(hit.currentStreak).toBe(3); + expect(hit.description).toBe('3-game hit streak'); + }); + + test('quality-start streak uses IP + ER', () => { + const pitcher = { + name: 'Strider', + games: [ + { inningsPitched: 7, earnedRuns: 2 }, + { inningsPitched: 6, earnedRuns: 3 }, + { inningsPitched: 5, earnedRuns: 1 }, // < 6 IP breaks it + ], + }; + const streaks = computePlayerStreaks(pitcher, 'mlb'); + const qs = streaks.find((s) => s.type === 'qs_streak'); + expect(qs.currentStreak).toBe(2); + }); + + test('MLB specs do not produce NBA streak types', () => { + const player = { name: 'P', games: [{ hits: 2 }, { hits: 2 }] }; + const streaks = computePlayerStreaks(player, 'mlb'); + expect(streaks.every((s) => s.type !== 'points_25')).toBe(true); + }); +}); + +describe('streaksService — fan-out + filtering', () => { + const roster = [ + { name: 'A', games: [{ points: 30 }, { points: 30 }, { points: 30 }] }, + { name: 'B', games: [{ assists: 9 }, { assists: 8 }] }, + { name: 'C', games: [{ points: 26 }, { points: 27 }] }, + ]; + + test('sorts by streak length descending', () => { + const streaks = computeStreaks(roster, 'nba'); + expect(streaks[0].player).toBe('A'); // 3-game points streak leads + expect(streaks[0].currentStreak).toBe(3); + }); + + test('stat filter narrows to a single category', () => { + const streaks = computeStreaks(roster, 'nba', { stat: 'points' }); + expect(streaks.every((s) => s.category === 'points')).toBe(true); + expect(streaks.map((s) => s.player).sort()).toEqual(['A', 'C']); + }); + + test('limit caps the result', () => { + const streaks = computeStreaks(roster, 'nba', { limit: 1 }); + expect(streaks).toHaveLength(1); + }); + + test('all filter (default) returns every category', () => { + const streaks = computeStreaks(roster, 'nba', { stat: 'all' }); + const cats = new Set(streaks.map((s) => s.category)); + expect(cats.has('points')).toBe(true); + expect(cats.has('assists')).toBe(true); + }); +}); diff --git a/tests/unit/tank01MlbAdapter.test.js b/tests/unit/tank01MlbAdapter.test.js index 57fb003..5be3006 100644 --- a/tests/unit/tank01MlbAdapter.test.js +++ b/tests/unit/tank01MlbAdapter.test.js @@ -142,5 +142,28 @@ describe('tank01MlbAdapter', () => { const games = await adapter.getMLBDailyScoreboard('20260611'); expect(games[0].gameId).toBe('STALE'); }); + + // Session 23 — game-level book-by-book betting odds. + test('getMLBBettingOdds returns the raw body and caches at the odds TTL', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { body: { '20260612_ARI@CIN': { sportsBooks: [{ sportsBook: 'bet365', odds: {} }] } } }, + }); + const body = await adapter.getMLBBettingOdds('2026-06-12'); + expect(body['20260612_ARI@CIN']).toBeDefined(); + const [url] = mockAxiosGet.mock.calls[0]; + expect(url).toMatch(/getMLBBettingOdds\?gameDate=20260612/); + expect(mockCacheTtls.get('tank01:mlb:odds:20260612')).toBe(adapter.__internals.TTL.odds); + }); + + test('getMLBBettingOdds second call within TTL does not hit Tank01', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { body: { g: {} } } }); + await adapter.getMLBBettingOdds('2026-06-12'); + await adapter.getMLBBettingOdds('2026-06-12'); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + }); + + test('getMLBBettingOdds null date returns null without axios', async () => { + expect(await adapter.getMLBBettingOdds(null)).toBeNull(); + }); }); }); diff --git a/web/public/sw.js b/web/public/sw.js index 7aecd0a..2d003ad 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s,r,n={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"u">typeof registration?registration.scope:""},i=e=>[n.prefix,e,n.suffix].filter(e=>e&&e.length>0).join("-"),c=e=>e||i(n.precache),o=e=>e||i(n.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 m(e,t,a,s){let r=d(t.url,a);if(t.url===r)return e.match(t,s);let n={...s,ignoreSearch:!0};for(let i of(await e.keys(t,n)))if(r===d(i.url,a))return e.match(i,s)}var f=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let g=async()=>{for(let e of u)await e()},w="-precache-",p=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),x=new WeakMap,b=new WeakMap,v=new WeakMap,E={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return x.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return R(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function R(e){if(e instanceof IDBRequest){let t;return t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",n)},r=()=>{t(R(e.result)),s()},n=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",n)}),v.set(t,e),t}if(b.has(e))return b.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(q(this),t),R(this.request)}:function(...t){return R(e.apply(q(this),t))};return(e instanceof IDBTransaction&&function(e){if(x.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",n),e.removeEventListener("abort",n)},r=()=>{t(),s()},n=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",n),e.addEventListener("abort",n)});x.set(e,t)}(e),_(e,s||(s=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,E):e}(e);return t!==e&&(b.set(e,t),v.set(t,e)),t}let q=e=>v.get(e);function S(e,t,{blocked:a,upgrade:s,blocking:r,terminated:n}={}){let i=indexedDB.open(e,t),c=R(i);return s&&i.addEventListener("upgradeneeded",e=>{s(R(i.result),e.oldVersion,e.newVersion,R(i.transaction),e)}),a&&i.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{n&&e.addEventListener("close",()=>n()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let D=["get","getKey","getAll","getAllKeys","count"],N=["put","add","delete","clear"],C=new Map;function T(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(C.get(t))return C.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=N.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||D.includes(a)))return;let n=async function(e,...t){let n=this.transaction(e,r?"readwrite":"readonly"),i=n.store;return s&&(i=i.index(t.shift())),(await Promise.all([i[a](...t),r&&n.done]))[0]};return C.set(t,n),n}E={...e=E,get:(t,a,s)=>T(t,a)||e.get(t,a,s),has:(t,a)=>!!T(t,a)||e.has(t,a)};let P=["continue","continuePrimaryKey","advance"],k={},A=new WeakMap,I=new WeakMap,U={get(e,t){if(!P.includes(t))return e[t];let a=k[t];return a||(a=k[t]=function(...e){A.set(this,I.get(this)[t](...e))}),a}};async function*L(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,U);for(I.set(a,t),v.set(a,q(t));t;)yield a,t=await (A.get(a)||t.continue()),A.delete(a)}function F(e,t){return t===Symbol.asyncIterator&&_(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&_(e,[IDBIndex,IDBObjectStore])}E={...t=E,get:(e,a,s)=>F(e,a)?L:t.get(e,a,s),has:(e,a)=>F(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(),n={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},i=t?t(n):n,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,i)},O="requests",B="queueName";var K=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(O,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(O).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(O,B,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(O,B,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(O,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(O).store.index(B).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await S("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(O)&&e.deleteObjectStore(O),e.createObjectStore(O,{autoIncrement:!0,keyPath:"id"}).createIndex(B,B,{unique:!1})}},W=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new K}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 $=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 H="serwist-background-sync",V=new Set,G=e=>{let t={request:new $(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var Q=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(G(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 $.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):G(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(`${H}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${H}:${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 Q(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 f,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 n=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:n,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:n.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,n=await this.getCacheKey(a,"read"),i={...r,cacheName:s};for(let e of(t=await caches.match(n,i),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:n,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:n,matchOptions:i}=this._strategy,c=await self.caches.open(n),o=this.hasCallback("cacheDidUpdate"),u=o?await m(c,s.clone(),["__WB_REVISION__"],i):null;try{await c.put(s,o?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await g(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:n,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 n of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await n({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,n;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(n=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:n}),t.destroy(),n)throw n}},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:n,promise:i}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=n,r.push(i)}let n=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(n);let i=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await n)());if(!i)throw new l("no-response",{url:e.url});return i}_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,n;try{n=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!n)&&(n=await s.cacheMatch(t)),n}},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,n=e.integrity,i=!n||n===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?n||r:void 0})),r&&i&&"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)}},en=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))}},ei=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 n=await a(r.item);t.push({result:n,index:r.index})}},n=Array.from({length:e},()=>new Promise(r));return(await Promise.all(n)).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)),R(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 n=a.value;n.cacheName===this._cacheName&&(e&&n.timestamp=t?(a.delete(),s.push(n.url)):r++),a=await a.continue()}return s}async getDb(){return this._db||(this._db=await S("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}},em=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 em(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),n=this._getCacheExpiration(t),i="last-used"===this._config.maxAgeFrom,c=(async()=>{i&&await n.updateTimestamp(a.url),await n.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 eg=/^\/(\w+\/)?collect/,ew=({serwist:e,cacheName:t,...a})=>{let s,r,c=t||i(n.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,n=new URL(s.url);try{let e="POST"===s.method?new URLSearchParams(await s.clone().text()):n.searchParams,t=r-(Number(e.get("qt"))||0),i=Date.now()-t;if(e.set("qt",String(i)),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(n.origin+n.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&&eg.test(e.pathname),r=new et({plugins:[o]}),"GET"),new es(s,r,"POST")])e.registerRoute(t)};var ep=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}}};let ey=async(e,t)=>{try{if(206===t.status)return t;let a=e.headers.get("range");if(!a)throw new l("no-range-header");let s=(e=>{let t=e.trim().toLowerCase();if(!t.startsWith("bytes="))throw new l("unit-must-be-bytes",{normalizedRangeHeader:t});if(t.includes(","))throw new l("single-range-only",{normalizedRangeHeader:t});let a=/(\d*)-(\d*)/.exec(t);if(!a||!(a[1]||a[2]))throw new l("invalid-range-values",{normalizedRangeHeader:t});return{start:""===a[1]?void 0:Number(a[1]),end:""===a[2]?void 0:Number(a[2])}})(a),r=await t.blob(),n=((e,t,a)=>{let s,r,n=e.size;if(a&&a>n||t&&t<0)throw new l("range-not-satisfiable",{size:n,end:a,start:t});return void 0!==t&&void 0!==a?(s=t,r=a+1):void 0!==t&&void 0===a?(s=t,r=n):void 0!==a&&void 0===t&&(s=n-a,r=n),{start:s,end:r}})(r,s.start,s.end),i=r.slice(n.start,n.end),c=i.size,o=new Response(i,{status:206,statusText:"Partial Content",headers:t.headers});return o.headers.set("Content-Length",String(c)),o.headers.set("Content-Range",`bytes ${n.start}-${n.end-1}/${r.size}`),o}catch(e){return new Response("",{status:416,statusText:"Range Not Satisfiable"})}};var e_=class{cachedResponseWillBeUsed=async({request:e,cachedResponse:t})=>t&&e.headers.has("range")?await ey(e,t):t},ex=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}},eb=class extends Z{constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y)}async _handle(e,t){let a,s=t.fetchAndCachePut(e).catch(()=>{});t.waitUntil(s);let r=await t.cacheMatch(e);if(r);else try{r=await s}catch(e){e instanceof Error&&(a=e)}if(!r)throw new l("no-response",{url:e.url,error:a});return r}},ev=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 n=new URL(e,location.href);n.hash="",yield n.href;let i=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(n,a);if(yield i.href,t&&i.pathname.endsWith("/")){let e=new URL(i.href);e.pathname+=t,yield e.href}if(s){let e=new URL(i.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:n}))yield e.href}(a.url,t)){let t=s.get(r);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eE=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}},eR=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:i,clientsClaim:o=!1,runtimeCaching:l,offlineAnalyticsConfig:h,disableDevLogs:u=!1,fallbacks:d,requestRules:m}={}){const{precacheStrategyOptions:f,precacheRouteOptions:g,precacheMiscOptions:w}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:n,fallbackToNetwork:i,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:m=10,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}=t??{};return{precacheStrategyOptions:{cacheName:c(a),plugins:[...s,new eE({precacheController:e})],fetchOptions:r,matchOptions:n,fallbackToNetwork:i},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:m,navigateFallback:f,navigateFallbackAllowlist:g,navigateFallbackDenylist:w}}})(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new er(f),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=m,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!==i&&(e=>{var t=e;for(let e of Object.keys(n))(e=>{let a=t[e];"string"==typeof a&&(n[e]=a)})(e)})({prefix:i}),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(p(c(e)).then(e=>{}))})})(f.cacheName),this.registerRoute(new ev(this,g)),w.navigateFallback&&this.registerRoute(new en(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 ep({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),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.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 ei(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:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.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:n})}catch(e){a=Promise.reject(e)}let l=i?.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:n})}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 n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':'1aa013c5b633c35fbffd48069b861e0c','url':'/_next/static/-mA5vlusgqAC8l4LniGPY/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/-mA5vlusgqAC8l4LniGPY/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1896-359ed2e68a8c4f1d.js'},{'revision':null,'url':'/_next/static/chunks/1958-56cbbb8dba15fd2f.js'},{'revision':null,'url':'/_next/static/chunks/2346-d63ba3de1d09a41e.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/6810-65ea0987ee90a78b.js'},{'revision':null,'url':'/_next/static/chunks/7602.2868ff821d53ee09.js'},{'revision':null,'url':'/_next/static/chunks/7918-2500511be32ff738.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-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/admin/page-7653c5a94021e122.js'},{'revision':null,'url':'/_next/static/chunks/app/api/admin/stats/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hero-prop/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-0cb8d3790d034de0.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-fc5693c59ad94635.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-a251f52b4206e8d1.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-34d758f67cb52da7.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-5f6c106fd6c53851.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-2d5d636ce98fef68.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-3bfc2406ebeb6d2e.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-69c1ebd6ff5c12d8.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-cbada49ae96a7527.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-1fd99a98dab3c771.js'},{'revision':null,'url':'/_next/static/chunks/app/page-96682b75258c4940.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-3f26af475b3f8452.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-44681f894156db65.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-fc810e8b5e4f992b.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-59df87aff0c62ec8.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-33e502bb04569f60.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-9ba3b46b0b1dfa59.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-d6937e1faa494c49.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-1fd99a98dab3c771.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-0ed948b44f9c64d7.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-b6baded9f44ae951.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-cdeec62ab3a5d8ff.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-e3ab6d5822dee6dc.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-4231d5f500f33972.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-756d787dba63aa4d.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-cdfd9ff3adbcee2e.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-a41d593db6ea66d4.js'},{'revision':null,'url':'/_next/static/css/f795112d016cc138.css'},{'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':'27007d181514e7fe4213035736a8300e','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("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:n="/"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",data:{url:n}}))}),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),n=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:n,url:new URL(n.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 ei(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:n,route:i}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=i?.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:n})}catch(e){a=Promise.reject(e)}let l=i?.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:n})}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 n,i=r.match({url:e,sameOrigin:t,request:a,event:s});if(i)return Array.isArray(n=i)&&0===n.length||i.constructor===Object&&0===Object.keys(i).length?n=void 0:"boolean"==typeof i&&(n=void 0),{route:r,params:n}}return{}}};let eq=[{matcher:/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,handler:new ex({cacheName:"google-fonts-webfonts",plugins:[new ef({maxEntries:4,maxAgeSeconds:31536e3,maxAgeFrom:"last-used"})]})},{matcher:/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,handler:new eb({cacheName:"google-fonts-stylesheets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,handler:new eb({cacheName:"static-font-assets",plugins:[new ef({maxEntries:4,maxAgeSeconds:604800,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,handler:new eb({cacheName:"static-image-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:2592e3,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/static.+\.js$/i,handler:new ex({cacheName:"next-static-js-assets",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/image\?url=.+$/i,handler:new eb({cacheName:"next-image",plugins:[new ef({maxEntries:64,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:mp3|wav|ogg)$/i,handler:new ex({cacheName:"static-audio-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:mp4|webm)$/i,handler:new ex({cacheName:"static-video-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"}),new e_]})},{matcher:/\.(?:js)$/i,handler:new eb({cacheName:"static-js-assets",plugins:[new ef({maxEntries:48,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:css|less)$/i,handler:new eb({cacheName:"static-style-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/_next\/data\/.+\/.+\.json$/i,handler:new ee({cacheName:"next-data",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\.(?:json|xml|csv)$/i,handler:new ee({cacheName:"static-data-assets",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400,maxAgeFrom:"last-used"})]})},{matcher:/\/api\/auth\/.*/,handler:new et({networkTimeoutSeconds:10})},{matcher:({sameOrigin:e,url:{pathname:t}})=>e&&t.startsWith("/api/"),method:"GET",handler:new ee({cacheName:"apis",plugins:[new ef({maxEntries:16,maxAgeSeconds:86400,maxAgeFrom:"last-used"})],networkTimeoutSeconds:10})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&"1"===e.headers.get("Next-Router-Prefetch")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc-prefetch",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>"1"===e.headers.get("RSC")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages-rsc",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({request:e,url:{pathname:t},sameOrigin:a})=>e.headers.get("Content-Type")?.includes("text/html")&&a&&!t.startsWith("/api/"),handler:new ee({cacheName:"pages",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({url:{pathname:e},sameOrigin:t})=>t&&!e.startsWith("/api/"),handler:new ee({cacheName:"others",plugins:[new ef({maxEntries:32,maxAgeSeconds:86400})]})},{matcher:({sameOrigin:e})=>!e,handler:new ee({cacheName:"cross-origin",plugins:[new ef({maxEntries:32,maxAgeSeconds:3600})],networkTimeoutSeconds:10})},{matcher:/.*/i,method:"GET",handler:new et}];new eR({precacheEntries:[{'revision':'1aa013c5b633c35fbffd48069b861e0c','url':'/_next/static/cfUWKtHLsAhi-PsEa8rB4/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/cfUWKtHLsAhi-PsEa8rB4/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1896-359ed2e68a8c4f1d.js'},{'revision':null,'url':'/_next/static/chunks/1958-56cbbb8dba15fd2f.js'},{'revision':null,'url':'/_next/static/chunks/2346-d63ba3de1d09a41e.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/6810-65ea0987ee90a78b.js'},{'revision':null,'url':'/_next/static/chunks/7602.2868ff821d53ee09.js'},{'revision':null,'url':'/_next/static/chunks/7918-2500511be32ff738.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-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/admin/page-7653c5a94021e122.js'},{'revision':null,'url':'/_next/static/chunks/app/api/admin/stats/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hero-prop/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-0cb8d3790d034de0.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-1b62da9adc97d4c5.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-a251f52b4206e8d1.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-34d758f67cb52da7.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-5f6c106fd6c53851.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-2d5d636ce98fef68.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-3bfc2406ebeb6d2e.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-69c1ebd6ff5c12d8.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-cbada49ae96a7527.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-1fd99a98dab3c771.js'},{'revision':null,'url':'/_next/static/chunks/app/page-2894b8877e22e9ec.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-3f26af475b3f8452.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-44681f894156db65.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-fc810e8b5e4f992b.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-59df87aff0c62ec8.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-33e502bb04569f60.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-9ba3b46b0b1dfa59.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-d6937e1faa494c49.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-1fd99a98dab3c771.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-0ed948b44f9c64d7.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-b6baded9f44ae951.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-cdeec62ab3a5d8ff.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-e3ab6d5822dee6dc.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-4231d5f500f33972.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-cdfd9ff3adbcee2e.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-756d787dba63aa4d.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-cdfd9ff3adbcee2e.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-a41d593db6ea66d4.js'},{'revision':null,'url':'/_next/static/css/f795112d016cc138.css'},{'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':'27007d181514e7fe4213035736a8300e','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("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:n="/"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",data:{url:n}}))}),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/page.tsx b/web/src/app/page.tsx index 1653dc1..b9813ce 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -10,6 +10,11 @@ import Hero from '@/components/Hero'; // every sport returns zero (off-hours / upstream outages). import TonightsSlate from '@/components/TonightsSlate'; import LivePropsStrip from '@/components/LivePropsStrip'; +// Session 23 — all-day intelligence teasers. Free/cheap content that +// keeps the landing page alive even when odds-api props are empty. +// Both self-hide when there's nothing to show. +import StreaksPanel from '@/components/StreaksPanel'; +import HotListPanel from '@/components/HotListPanel'; import Features from '@/components/Features'; import HowItWorks from '@/components/HowItWorks'; import Pricing from '@/components/Pricing'; @@ -41,6 +46,10 @@ export default function Home() { +
+ + +
diff --git a/web/src/components/HotListPanel.tsx b/web/src/components/HotListPanel.tsx new file mode 100644 index 0000000..2c52832 --- /dev/null +++ b/web/src/components/HotListPanel.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; +import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate'; + +/** + * HotListPanel (Session 23). + * + * Rolling recent-window leaders — ranked by who's TRENDING, not who has + * the biggest raw number. A 20-PPG player erupting for 28/31/25 is hot; + * a 30-PPG star who dropped 28 is not. Free users see the top 3. + * + * Self-hides when empty so the landing page never renders a dead box. + */ + +interface HotPlayer { + rank: number; + name: string; + playerId: string | number | null; + team: string | null; + stat: string; + recentAvg: number; + statLine: string; + trendDescription: string; +} + +export interface HotListPanelProps { + sport: string; + tier?: Tier; + stat?: string; + limit?: number; +} + +export default function HotListPanel({ sport, tier = 'free', stat = 'all', limit }: HotListPanelProps) { + const [players, setPlayers] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const res = await fetch(`/api/hotlist/${sport}?stat=${encodeURIComponent(stat)}`); + if (!res.ok) { if (!cancelled) setPlayers([]); return; } + const data = await res.json(); + if (!cancelled) setPlayers(Array.isArray(data?.players) ? data.players : []); + } catch { + if (!cancelled) setPlayers([]); + } + } + load(); + return () => { cancelled = true; }; + }, [sport, stat]); + + if (!players || players.length === 0) return null; + + const tierCount = getVisibleCount(tier, players.length); + const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount; + const visible = players.slice(0, cap); + const hidden = limit ? players.length - visible.length : getHiddenCount(tier, players.length); + + return ( +
+

📈 HOT RIGHT NOW

+
+ {visible.map((p) => ( +
+ #{p.rank} + {p.name} { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} + /> +
+
{p.name}{p.team ? ` · ${p.team}` : ''}
+
{p.statLine}
+
+ {p.trendDescription} +
+ ))} +
+ {hidden > 0 && ( + + {hidden} more — upgrade to see the full board → + + )} +
+ ); +} + +const panelHeading: React.CSSProperties = { + fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', + color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px', +}; +const rowStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 10, + padding: '8px 10px', borderRadius: 10, + background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)', +}; +const rankStyle: React.CSSProperties = { + flex: '0 0 auto', fontSize: 13, fontWeight: 800, width: 28, + color: 'var(--text-tertiary, #6A6A78)', +}; +const avatarStyle: React.CSSProperties = { + borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto', +}; +const playerName: React.CSSProperties = { + fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', +}; +const statLineStyle: React.CSSProperties = { + fontSize: 12, color: 'var(--text-secondary, #9A9AA8)', +}; +const trendStyle: React.CSSProperties = { + flex: '0 0 auto', fontSize: 11, fontWeight: 700, padding: '3px 8px', + borderRadius: 6, background: 'rgba(46,160,67,0.15)', color: '#3FB950', +}; +const upsellStyle: React.CSSProperties = { + display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600, + color: 'var(--accent, #E94B3C)', textDecoration: 'none', +}; diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index ad65182..387f52a 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -5,6 +5,12 @@ import { useRouter } from 'next/navigation'; import GameCard, { SlateSport } from '@/components/GameCard'; import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow'; import { useAuth } from '@/contexts/AuthContext'; +// Session 23 — all-day intelligence layer. The stat filter is the +// navigation system; streaks + hot lists layer ON TOP of the odds the +// Slate already shows, never replacing them. +import StatFilterPills from '@/components/StatFilterPills'; +import StreaksPanel from '@/components/StreaksPanel'; +import HotListPanel from '@/components/HotListPanel'; /** * The Slate (Session 13). @@ -181,6 +187,10 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) const router = useRouter(); const { session } = useAuth(); const [tab, setTab] = useState(initialTab); + // Session 23 — active stat category for the intelligence panels. 'all' + // shows everything; selecting one narrows streaks + hot list. Schedule + // and game lines stay visible regardless (handled inside GameCard). + const [activeStat, setActiveStat] = useState('all'); const [games, setGames] = useState([]); const [loading, setLoading] = useState(false); const [fetchError, setFetchError] = useState(null); @@ -416,6 +426,13 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) ); })} + {/* Session 23 — stat filter pills, below the sport tabs and above + all content. Narrows the streaks + hot list panels. */} + {/* Body */} @@ -525,6 +542,12 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) ))} + {/* Session 23 — intelligence layer. These coexist WITH the odds + above; they never replace games. Both self-hide when empty, so + an off-hours slate with no warm logs simply shows the games. */} + + + {unsupportedSports.length > 0 && !loading && (

void; +} + +export default function StatFilterPills({ sport, activeStat, onChange }: StatFilterPillsProps) { + const filters = getStatFilters(sport); + if (filters.length <= 1) return null; // nothing to filter by + + return ( +

+ {filters.map((stat) => { + const active = activeStat === stat; + return ( + + ); + })} +
+ ); +} diff --git a/web/src/components/StreaksPanel.tsx b/web/src/components/StreaksPanel.tsx new file mode 100644 index 0000000..43950ad --- /dev/null +++ b/web/src/components/StreaksPanel.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { getHeadshotUrl, PLAYER_SILHOUETTE } from '@/lib/playerHeadshot'; +import { getVisibleCount, getHiddenCount, type Tier } from '@/lib/tierGate'; + +/** + * StreaksPanel (Session 23). + * + * Surfaces computed player streaks for a sport, narrowed by the active + * stat filter. Everything through VYNDR's lens — "4-game 28+ scoring + * streak", not "31.2 PPG". Free users see the top 3 with an upgrade + * nudge; paid users see the full list. + * + * Self-hides when there are no streaks so the landing page never shows an + * empty box — the other layers (schedule, game lines, props) carry the + * slate when no logs are warm yet. + */ + +interface Streak { + player: string; + playerId: string | number | null; + team: string | null; + type: string; + category: string; + currentStreak: number; + description: string; +} + +export interface StreaksPanelProps { + sport: string; + tier?: Tier; + stat?: string; + /** Optional hard cap (teaser usage on the landing page). */ + limit?: number; +} + +export default function StreaksPanel({ sport, tier = 'free', stat = 'all', limit }: StreaksPanelProps) { + const [streaks, setStreaks] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const res = await fetch(`/api/streaks/${sport}?stat=${encodeURIComponent(stat)}`); + if (!res.ok) { if (!cancelled) setStreaks([]); return; } + const data = await res.json(); + if (!cancelled) setStreaks(Array.isArray(data?.streaks) ? data.streaks : []); + } catch { + if (!cancelled) setStreaks([]); + } + } + load(); + return () => { cancelled = true; }; + }, [sport, stat]); + + if (!streaks || streaks.length === 0) return null; + + const tierCount = getVisibleCount(tier, streaks.length); + const cap = limit && limit > 0 ? Math.min(limit, tierCount) : tierCount; + const visible = streaks.slice(0, cap); + const hidden = limit ? streaks.length - visible.length : getHiddenCount(tier, streaks.length); + + return ( +
+

🔥 STREAKS

+
+ {visible.map((s) => ( +
+ {s.player} { (e.target as HTMLImageElement).src = PLAYER_SILHOUETTE; }} + /> +
+
{s.player}{s.team ? ` · ${s.team}` : ''}
+
{s.description}
+
+ {s.currentStreak} G +
+ ))} +
+ {hidden > 0 && ( + + {hidden} more streak{hidden === 1 ? '' : 's'} — upgrade to see all → + + )} +
+ ); +} + +const panelHeading: React.CSSProperties = { + fontSize: 12, letterSpacing: '0.12em', textTransform: 'uppercase', + color: 'var(--text-tertiary, #6A6A78)', margin: '0 0 10px', +}; +const rowStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 10, + padding: '8px 10px', borderRadius: 10, + background: 'var(--surface, #12121A)', border: '1px solid var(--border, #2A2A36)', +}; +const avatarStyle: React.CSSProperties = { + borderRadius: '50%', objectFit: 'cover', background: '#1A1A24', flex: '0 0 auto', +}; +const playerName: React.CSSProperties = { + fontSize: 14, fontWeight: 700, color: 'var(--text-primary, #F0F0F4)', + whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', +}; +const streakDesc: React.CSSProperties = { + fontSize: 12, color: 'var(--text-secondary, #9A9AA8)', +}; +const badgeStyle: React.CSSProperties = { + flex: '0 0 auto', fontSize: 12, fontWeight: 800, padding: '3px 8px', + borderRadius: 6, background: 'rgba(233,75,60,0.15)', color: 'var(--accent, #E94B3C)', +}; +const upsellStyle: React.CSSProperties = { + display: 'inline-block', marginTop: 10, fontSize: 12, fontWeight: 600, + color: 'var(--accent, #E94B3C)', textDecoration: 'none', +}; diff --git a/web/src/config/statFilters.ts b/web/src/config/statFilters.ts new file mode 100644 index 0000000..80734d3 --- /dev/null +++ b/web/src/config/statFilters.ts @@ -0,0 +1,39 @@ +/** + * Stat-filter categories per sport — frontend mirror (Session 23). + * Keep aligned with `src/config/statFilters.js` in the Node backend. + * + * The stat filter is VYNDR's navigation system: users browse by what + * they care about (a 3-point streak, hot hitters, run lines), not by + * sport alone. Drives StatFilterPills and the `?stat=` param on the + * /api/streaks and /api/hotlist endpoints. + */ + +export type StatFilterSport = 'nba' | 'wnba' | 'mlb' | 'soccer' | 'nfl'; + +export const STAT_FILTERS: Record = { + nba: ['all', 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals', 'pra'], + wnba: ['all', 'points', 'rebounds', 'assists', 'threes', 'blocks', 'steals'], + mlb: ['all', 'hits', 'home_runs', 'stolen_bases', 'rbis', 'strikeouts', 'total_bases', 'on_base'], + soccer: ['all', 'goals', 'assists', 'shots', 'tackles', 'saves'], + nfl: ['all', 'passing_yards', 'rushing_yards', 'receiving_yards', 'touchdowns', 'interceptions'], +}; + +const LABELS: Record = { + all: 'All', + points: 'Points', rebounds: 'Rebounds', assists: 'Assists', threes: '3-Pointers', + blocks: 'Blocks', steals: 'Steals', pra: 'PRA', + hits: 'Hits', home_runs: 'Home Runs', stolen_bases: 'Stolen Bases', rbis: 'RBIs', + strikeouts: 'Strikeouts', total_bases: 'Total Bases', on_base: 'On-Base', + goals: 'Goals', shots: 'Shots', tackles: 'Tackles', saves: 'Saves', + passing_yards: 'Pass Yds', rushing_yards: 'Rush Yds', receiving_yards: 'Rec Yds', + touchdowns: 'TDs', interceptions: 'INTs', +}; + +export function getStatFilters(sport: string): string[] { + return STAT_FILTERS[sport as StatFilterSport] || ['all']; +} + +export function formatStatLabel(stat: string): string { + if (LABELS[stat]) return LABELS[stat]; + return stat.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +}