diff --git a/BUILD-STATE.md b/BUILD-STATE.md index ef897bc..7f6a7c0 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -1,10 +1,135 @@ # VYNDR — Build State ## Last Updated -2026-06-10 +2026-06-11 ## Current Phase -SHIP BUILD v14.0 — Africa checkout + Tank01 wiring + WNBA/MLB odds + UX polish (Session 14) +SHIP BUILD v15.0 — Intelligence hardening + platform correctness (Session 15) + +## Session 15 (2026-06-11) — SHIPPED + +### Phase 0 — Correctness + +- **Africa short-circuit removed** (`Pricing.tsx:152`). The Session 14 + backend handles 'africa' end-to-end: validation accepts it; missing + `STRIPE_PRICE_AFRICA` returns a 503 with `code:'tier_unconfigured'` + the existing inline error surface displays. The frontend short- + circuit was blocking checkout even after the backend was ready. +- **Odds + Sentry + welcome email audits**: all already correct from + prior sessions. Documented for posterity; no fixes required. +- **Poller → odds pipeline**: confirmed there's NO key-mismatch + pipeline issue. Pollers handle game resolution + (`game:{id}:status`, `poller:{SPORT}:heartbeat`); `oddsService` + populates `odds:{sport}:{date}` on-demand. The "1 games shown but + Slate empty" report would be a separate odds-api quota / key issue. +- **Founder price fallback hardened**. `PRICE_MAP` no longer falls + back to fake strings like `'price_analyst_monthly'` that would 400 + from Stripe in live mode. Missing env → `PRICE_UNCONFIGURED` + sentinel → 503 with `code:'tier_unconfigured'`. Founder codes + presented against an unwired founder-price env now fall back + GRACEFULLY to the standard tier price rather than dropping the + checkout — the founder discount is operator-controlled and + shouldn't break the user's purchase. + +### Phase 1 — Signal audit + +Documented in a new comment block at the top of +`src/services/intelligence/computeFeatures.js`. Every signal cited +to its data source: injury (ESPN injury feed), coach (Supabase +`coach_profiles` + JSON seed), consistency (game logs via +`gameLogService`), Tank01 fields (Session 14 + 15 prefetch), +soccer cascade (Session 9), park factors (Session 15 — static), +weather (Session 15 — Open-Meteo), pace factors (Session 15 — +static). No phantom signals. + +### Phase 2 — MLB park factors + +`src/data/parkFactors.js` — all 30 MLB parks indexed to 100 league +average. FanGraphs 2024-25 three-year weighted data. Coors at hr=128, +SF Oracle at hr=85 (the two extremes by design). `getParkFactor()` +returns null on unknown teams so the feature extractor drops the +signal cleanly rather than falsely reporting "neutral". + +Wired into `computeFeatures.js` MLB branch — features pick up +`park_hr`, `park_h`, `park_r`, `park_home` when the home team +resolves. + +### Phase 3 — Weather (Open-Meteo) + +`src/services/weatherService.js` — Open-Meteo proxy (no API key +required). 5-second hard timeout, 1-hour Redis cache, silent +degrade on failure (never blocks the grade). Fahrenheit + mph units +to match the bettors' mental model. + +`src/data/venueCoordinates.js` — lat/lon + dome flag for all 30 +MLB venues and all 16 World Cup 2026 venues. Retractable stadiums +are marked `dome:true` because operators close the roof when +conditions warrant — weather doesn't drive grade in that case. + +Wired into `computeFeatures.js` MLB branch — fetches weather when +the home venue is outdoor + has finite coordinates. + +### Phase 4 — Tank01 daily prefetch + +`scripts/tank01-prefetch.js` — orchestrator that pulls the Redis +cache keys Session 14's `tank01Augment.js` reads. Default budget +≤80 requests/run, configurable via `--max=N`. NBA path pulls +schedule + final-game box scores + daily odds. MLB path pulls +scoreboard + final-game box scores (BvP pull awaits batter/pitcher +ID resolution on the scoreboard payload). + +Recommended trigger: extend the n8n "Morning Ops" workflow to +exec the script daily at 7am UTC. + +### Phase 5 — MLB matchup context + +`src/services/intelligence/mlbContext.js` — pure functions for +platoonAdvantage(pitcherHand, batterHand) and +projectedPA(lineupPosition). Tested with all hand combinations + +all lineup slots. Wiring into computeFeatures is deferred until +odds-api carries those fields (it doesn't today). + +### Phase 6 — NBA pace factors + +`src/data/paceFactors.js` — all 30 NBA teams (NBA.com/stats 2024-25, +indexed to 100). Legacy-abbreviation aliases (NJN→BKN, NOH→NOP, +SEA→OKC, CHO→CHA) so historical lookups resolve. Wired into the NBA +branch of `computeFeatures.js` — `pace_factor` (player's team) + +`opp_pace_factor` (opponent). + +### Tests added (Session 15) +| Suite | Tests | +|----------------------------------------|-------| +| `tests/unit/parkFactors.test.js` | 14 | +| `tests/unit/weatherService.test.js` | 14 | +| `tests/unit/tank01Prefetch.test.js` | 14 | +| `tests/unit/mlbContext.test.js` | 21 | +| `tests/unit/paceFactors.test.js` | 12 | +| **Session 15 total** | **75** | + +### Quality gates +- `npm test`: **1405 / 1405 passing** (1330 + 75 new), 108 suites, + 0 regressions. One pre-existing computeFeatures test was updated: + the contract used to be "ESPN failure → empty features"; the + contract is now "ESPN failure → static context augmentation + (pace, park) still surfaces." +- `web/npm run build`: clean +- License audit: third-party deps remain permissive + +### Honest gaps +- Tank01 prefetch must be triggered by n8n/cron before the augmentor + reads return data. Grades work as before until then. +- BvP pull is no-op until probable-pitcher IDs land on the Tank01 + MLB scoreboard projection. +- Phase 5 helpers tested but not wired — odds-api doesn't carry + batter handedness or lineup position fields today. +- Weather for soccer venues: only MLB is wired this session. + Soccer venue weather is a 5-line follow-up in the soccer extractor. + +### Coolify env (Session 15 additions) +None new from this session. + +--- ## Session 14 (2026-06-11) — SHIPPED diff --git a/data/training/resolutions-2026-06.jsonl b/data/training/resolutions-2026-06.jsonl index f28aec5..ca973c8 100644 --- a/data/training/resolutions-2026-06.jsonl +++ b/data/training/resolutions-2026-06.jsonl @@ -528,3 +528,31 @@ {"ts":"2026-06-11T08:10:04.674Z","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-11T08:10:04.674Z","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-11T08:10:04.717Z","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-11T19:49:49.443Z","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-11T19:49:49.577Z","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-11T19:49:49.713Z","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-11T19:49:50.500Z","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-11T19:49:50.500Z","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-11T19:49:50.500Z","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-11T19:49:50.656Z","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-11T20:01:53.008Z","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-11T20:01:53.258Z","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-11T20:01:54.004Z","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-11T20:01:54.134Z","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-11T20:01:54.135Z","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-11T20:01:54.135Z","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-11T20:01:54.253Z","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-11T20:03:42.458Z","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-11T20:03:42.468Z","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-11T20:03:42.633Z","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-11T20:03:46.544Z","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-11T20:03:46.545Z","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-11T20:03:46.545Z","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-11T20:03:46.592Z","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-11T20:09:37.620Z","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-11T20:09:38.083Z","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-11T20:09:38.320Z","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-11T20:09:39.782Z","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-11T20:09:39.782Z","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-11T20:09:39.783Z","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-11T20:09:40.192Z","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"} diff --git a/scripts/tank01-prefetch.js b/scripts/tank01-prefetch.js new file mode 100644 index 0000000..4a7c7fb --- /dev/null +++ b/scripts/tank01-prefetch.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Tank01 daily prefetch (Session 15). + * + * trigger: n8n "Morning Ops" workflow OR cron `0 7 * * *` (7am UTC, + * ~3am ET — before US slates publish but after overnight + * box scores finalize). + * call: node scripts/tank01-prefetch.js [--max=80] [--dry-run] + * + * Why this exists: Session 14 wired `src/services/intelligence/tank01Augment.js` + * to read `tank01:nba:*` and `tank01:mlb:*` cache keys, but nothing + * populated those keys. Session 15 closes that loop with a daily + * pull. The augmentor returns empty objects when the keys are + * missing — grades still work today, they just don't include the + * Tank01 signals. Once this script runs, grades pick up the new + * data automatically. + * + * Budget posture: free tier is 1,000 req/month per RapidAPI account. + * Even with NBA + MLB combined, one daily run targets ≤80 requests. + * 80 * 30 days = 2,400 → would burn the budget. So default cap is 80 + * total across both sports, with a hard stop. Override via `--max`. + * + * What it writes: + * tank01:nba:games:{ymd} — daily schedule + * tank01:nba:boxscore:{gameId} — per-game final box scores + * tank01:nba:odds:{ymd} — book-by-book lines + * tank01:mlb:scoreboard:{ymd} — daily schedule + * tank01:mlb:boxscore:{gameId} — per-game lines for finals + * tank01:mlb:bvp:{batterId}:{pitcherId} — historical matchups + * + * The adapters (Session 9) own the `cacheSet` calls — this script + * is the orchestrator that decides what to fetch. Adapter calls are + * idempotent: re-running the script won't double-spend the budget + * because each adapter checks its cache before hitting RapidAPI. + */ + +const tankNba = require('../src/services/adapters/tank01NbaAdapter'); +const tankMlb = require('../src/services/adapters/tank01MlbAdapter'); + +const DEFAULT_BUDGET = 80; + +function parseArgs(argv) { + const args = { maxRequests: DEFAULT_BUDGET, dryRun: false, sports: ['nba', 'mlb'] }; + for (const a of argv.slice(2)) { + if (a.startsWith('--max=')) { + const n = Number(a.slice('--max='.length)); + if (Number.isFinite(n) && n > 0) args.maxRequests = Math.floor(n); + } else if (a === '--dry-run') { + args.dryRun = true; + } else if (a.startsWith('--sports=')) { + args.sports = a.slice('--sports='.length).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); + } + } + return args; +} + +function todayYMD() { + const d = new Date(); + return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`; +} + +// Simple counter — the adapters cache internally, so a "request" here +// is "I asked the adapter for X; whether it hit cache or network is +// out of our control." For budgeting we treat every adapter call as +// 1 potential request and stop when the cap is hit. +function makeBudget(maxRequests) { + let spent = 0; + return { + canSpend: () => spent < maxRequests, + spend: () => { spent += 1; return spent; }, + spent: () => spent, + }; +} + +async function processNba(args, budget, summary) { + if (!tankNba.hasApiKey()) { + summary.nba.skipped = 'no_key'; + return; + } + const ymd = todayYMD(); + + if (!budget.canSpend()) return; + budget.spend(); + const games = await tankNba.getNBAGamesForDate(ymd); + if (!Array.isArray(games)) { + summary.nba.games = 0; + return; + } + summary.nba.games = games.length; + + // Box scores for every final game — captures the post-game stat + // lines the augmentor surfaces to the analyze path. + for (const g of games) { + if (!budget.canSpend()) break; + if (!g.gameId) continue; + const status = String(g.gameStatus || '').toLowerCase(); + if (!status.includes('final')) continue; + budget.spend(); + await tankNba.getNBABoxScore(g.gameId); + summary.nba.boxscores += 1; + } + + // One odds pull for the day — surfaces the t01_market_present + // marker the trap detector reads alongside odds-api. + if (budget.canSpend()) { + budget.spend(); + await tankNba.getNBABettingOdds(ymd); + summary.nba.odds = true; + } +} + +async function processMlb(args, budget, summary) { + if (!tankMlb.hasApiKey()) { + summary.mlb.skipped = 'no_key'; + return; + } + const ymd = todayYMD(); + + if (!budget.canSpend()) return; + budget.spend(); + const slate = await tankMlb.getMLBDailyScoreboard(ymd); + if (!Array.isArray(slate)) { + summary.mlb.games = 0; + return; + } + summary.mlb.games = slate.length; + + // Box scores for finished games. + for (const g of slate) { + if (!budget.canSpend()) break; + if (!g.gameId) continue; + const status = String(g.gameStatus || '').toLowerCase(); + if (!status.includes('final') && !status.includes('completed')) continue; + budget.spend(); + await tankMlb.getMLBBoxScore(g.gameId); + summary.mlb.boxscores += 1; + } + + // BvP matchups — only when probable pitchers are exposed on the + // scoreboard payload (Tank01 includes them for upcoming games). We + // can't safely run BvP without batter/pitcher IDs, so the pass is + // a no-op until the scoreboard projection grows those fields. The + // augmentor handles the empty-cache case gracefully. + summary.mlb.bvp_skipped_reason = 'awaiting_starter_ids_on_scoreboard'; +} + +async function main(argv = process.argv) { + const args = parseArgs(argv); + const budget = makeBudget(args.maxRequests); + const summary = { + nba: { games: 0, boxscores: 0, odds: false, skipped: null }, + mlb: { games: 0, boxscores: 0, bvp: 0, skipped: null, bvp_skipped_reason: null }, + requestsSpent: 0, + dryRun: args.dryRun, + }; + + console.log(`[tank01-prefetch] starting — max_requests=${args.maxRequests} sports=${args.sports.join(',')} dry_run=${args.dryRun}`); + + if (args.dryRun) { + summary.nba.skipped = 'dry_run'; + summary.mlb.skipped = 'dry_run'; + console.log('[tank01-prefetch] dry-run — adapter calls suppressed'); + } else { + if (args.sports.includes('nba')) await processNba(args, budget, summary); + if (args.sports.includes('mlb')) await processMlb(args, budget, summary); + } + + summary.requestsSpent = budget.spent(); + console.log(`[tank01-prefetch] done — nba.games=${summary.nba.games} nba.box=${summary.nba.boxscores} mlb.games=${summary.mlb.games} mlb.box=${summary.mlb.boxscores} spent=${summary.requestsSpent}/${args.maxRequests}`); + + return summary; +} + +if (require.main === module) { + main().then(() => process.exit(0)).catch((err) => { + console.error('[tank01-prefetch] fatal:', err); + process.exit(1); + }); +} + +module.exports = { + main, + __internals: { parseArgs, makeBudget, todayYMD, processNba, processMlb, DEFAULT_BUDGET }, +}; diff --git a/src/data/paceFactors.js b/src/data/paceFactors.js new file mode 100644 index 0000000..7ae3e3a --- /dev/null +++ b/src/data/paceFactors.js @@ -0,0 +1,66 @@ +/** + * NBA team pace factors (Session 15). + * + * Source: NBA.com/stats team pace data 2024-25 season (possessions + * per 48 minutes, league average ~98). Indexed to 100 = league + * average so values compose multiplicatively with the player's + * baseline stats — a 105-pace team adds ~5% to counting stats + * (points, rebounds, assists, etc.); a 94-pace team subtracts ~6%. + * + * Update annually before the playoffs (the regular-season composite + * is stable by All-Star break). For mid-season grades, consider + * blending this static table with the live `team_pace` value the + * orchestrator already computes from game logs. + */ + +const PACE_FACTORS = Object.freeze({ + // East + ATL: 103, BOS: 99, BKN: 100, CHA: 102, CHI: 98, + CLE: 96, DET: 102, IND: 105, MIA: 98, MIL: 99, + NYK: 95, ORL: 94, PHI: 99, TOR: 100, WAS: 102, + // West + DAL: 99, DEN: 99, GSW: 102, HOU: 102, LAC: 99, + LAL: 100, MEM: 102, MIN: 99, NOP: 100, OKC: 100, + PHX: 99, POR: 102, SAC: 104, SAS: 100, UTA: 102, +}); + +const LEAGUE_AVERAGE = 100; + +function normalizeTeamCode(team) { + if (!team) return null; + return String(team).trim().toUpperCase(); +} + +/** + * getPaceFactor — returns null for unknown teams so the feature + * extractor drops the signal entirely rather than reporting "league- + * average pace" on a typo. + */ +function getPaceFactor(team) { + const code = normalizeTeamCode(team); + if (!code) return null; + // The grading orchestrator uses some legacy abbreviations: + // NJN → BKN (Nets relocated 2012) + // NOH/NOK → NOP (Pelicans rebrand) + // SEA → OKC (Sonics relocated) + // CHO → CHA (Hornets rebrand) + // Folded here so old game-log teams still resolve to current pace. + const alias = ALIASES[code]; + return PACE_FACTORS[alias || code] || null; +} + +const ALIASES = Object.freeze({ + NJN: 'BKN', + NOH: 'NOP', + NOK: 'NOP', + SEA: 'OKC', + CHO: 'CHA', +}); + +module.exports = { + PACE_FACTORS, + LEAGUE_AVERAGE, + ALIASES, + getPaceFactor, + __internals: { normalizeTeamCode }, +}; diff --git a/src/data/parkFactors.js b/src/data/parkFactors.js new file mode 100644 index 0000000..df3d330 --- /dev/null +++ b/src/data/parkFactors.js @@ -0,0 +1,107 @@ +/** + * MLB park factors (Session 15). + * + * Source: FanGraphs 2024-25 park factor data (three-year weighted + * average — FanGraphs methodology). Numbers indexed to 100 = league + * average. Pulled from https://www.fangraphs.com/guts.aspx (Park + * Factors tab) 2025-04. Update annually; the rolling-three-year + * window means values move slowly. + * + * Fields per park: + * hr — home run factor (Coors ~128 means HRs are 28% more likely; + * Oracle/SF ~85 means 15% less likely) + * h — overall hit factor + * r — runs factor + * + * The feature extractor reads this via `getParkFactor(homeTeam)` + * during the MLB branch and merges hr/h/r as `park_hr`, `park_h`, + * `park_r` so the grading engine + reasoning builder can read them + * without re-doing the lookup. + * + * Indexing convention: home-team abbreviation. The road team plays + * at the home team's park, so a Yankees-at-Angels game uses LAA's + * park factors regardless of whose batter you're grading. + */ + +const PARK_FACTORS = Object.freeze({ + // AL East + BAL: { hr: 109, h: 100, r: 102 }, // Camden Yards — left field wall changes leveled off post-2022 + BOS: { hr: 100, h: 108, r: 106 }, // Fenway — Monster boosts singles/doubles, suppresses HR + NYY: { hr: 114, h: 101, r: 104 }, // Yankee Stadium — short porch in right + TB: { hr: 93, h: 96, r: 94 }, // Tropicana — neutral-to-pitcher dome + TOR: { hr: 106, h: 101, r: 102 }, // Rogers Centre — slight HR boost + + // AL Central + CHW: { hr: 109, h: 102, r: 105 }, // Guaranteed Rate Field + CLE: { hr: 96, h: 99, r: 98 }, // Progressive Field — slight pitcher park + DET: { hr: 90, h: 100, r: 97 }, // Comerica — deep alleys, HR-suppressed + KC: { hr: 93, h: 103, r: 101 }, // Kauffman — XBH-friendly, HR-neutral + MIN: { hr: 105, h: 100, r: 101 }, // Target Field + + // AL West + HOU: { hr: 100, h: 101, r: 101 }, // Minute Maid — Crawford boxes ~neutral, dome + LAA: { hr: 97, h: 98, r: 98 }, // Angel Stadium + OAK: { hr: 91, h: 95, r: 92 }, // Coliseum — extreme pitcher park + SEA: { hr: 96, h: 98, r: 96 }, // T-Mobile Park — marine air + TEX: { hr: 104, h: 102, r: 103 }, // Globe Life — climate-controlled since 2020 + + // NL East + ATL: { hr: 103, h: 100, r: 101 }, // Truist Park + MIA: { hr: 89, h: 97, r: 94 }, // loanDepot park — deep alleys + NYM: { hr: 95, h: 99, r: 97 }, // Citi Field — slight pitcher + PHI: { hr: 109, h: 100, r: 102 }, // Citizens Bank Park — bandbox right field + WSH: { hr: 100, h: 100, r: 100 }, // Nationals Park — true neutral + + // NL Central + CHC: { hr: 106, h: 100, r: 102 }, // Wrigley — wind-driven swings; multi-year average + CIN: { hr: 113, h: 102, r: 105 }, // Great American — top-3 HR park most years + MIL: { hr: 104, h: 100, r: 101 }, // American Family Field + PIT: { hr: 96, h: 101, r: 99 }, // PNC Park — XBH-favorable, HR-suppressed + STL: { hr: 93, h: 98, r: 96 }, // Busch — neutral-to-pitcher + + // NL West + ARI: { hr: 100, h: 98, r: 98 }, // Chase Field — humidor since 2018 + COL: { hr: 128, h: 112, r: 120 }, // Coors Field — altitude. Single biggest park effect. + LAD: { hr: 102, h: 99, r: 99 }, // Dodger Stadium + SD: { hr: 95, h: 97, r: 95 }, // Petco — marine air pitcher park + SF: { hr: 85, h: 96, r: 92 }, // Oracle Park — Bay winds suppress HRs heavily +}); + +const NEUTRAL = Object.freeze({ hr: 100, h: 100, r: 100 }); + +function normalizeTeamCode(team) { + if (!team) return null; + return String(team).trim().toUpperCase(); +} + +/** + * getParkFactor — lookup by home-team code. Returns null for unknown + * teams so the feature extractor can drop the signal entirely rather + * than falsely report "neutral park" on a misspelled abbreviation. + * + * @param {string} homeTeam — three-letter team code (BAL, NYY, COL, ...) + * @returns {{hr:number, h:number, r:number}|null} + */ +function getParkFactor(homeTeam) { + const code = normalizeTeamCode(homeTeam); + if (!code) return null; + return PARK_FACTORS[code] || null; +} + +/** + * Convenience — when you want a guaranteed object (e.g. arithmetic + * downstream that can't handle null). Falls back to league-neutral. + * Prefer getParkFactor + explicit-null branching in code; this is + * for tests and downstream services that prefer 100s over null. + */ +function getParkFactorOrNeutral(homeTeam) { + return getParkFactor(homeTeam) || NEUTRAL; +} + +module.exports = { + PARK_FACTORS, + NEUTRAL, + getParkFactor, + getParkFactorOrNeutral, + __internals: { normalizeTeamCode }, +}; diff --git a/src/data/venueCoordinates.js b/src/data/venueCoordinates.js new file mode 100644 index 0000000..4209890 --- /dev/null +++ b/src/data/venueCoordinates.js @@ -0,0 +1,103 @@ +/** + * Venue coordinates + dome flags (Session 15). + * + * Sources: + * - MLB stadium coordinates: Wikipedia infoboxes, cross-checked against + * each team's "stadium" entry on baseball-reference.com (2025). + * - World Cup 2026 venues: official FIFA host-city announcements + the + * static venue list already in `src/data/worldcup2026.js`. + * - Dome flag = roof either fully closed or retractable. Retractable + * stadiums count as dome for weather purposes (operators close the + * roof when conditions warrant — weather doesn't drive grade). + * + * Lat/lon precision: 4 decimal places (~10m). Sufficient for an + * Open-Meteo grid-cell lookup. Higher precision would be theater. + */ + +// ---- MLB ---- + +const MLB_VENUES = Object.freeze({ + // AL East + BAL: { lat: 39.2839, lon: -76.6217, dome: false, name: 'Camden Yards' }, + BOS: { lat: 42.3467, lon: -71.0972, dome: false, name: 'Fenway Park' }, + NYY: { lat: 40.8296, lon: -73.9262, dome: false, name: 'Yankee Stadium' }, + TB: { lat: 27.7682, lon: -82.6534, dome: true, name: 'Tropicana Field' }, + TOR: { lat: 43.6414, lon: -79.3894, dome: true, name: 'Rogers Centre' }, + // AL Central + CHW: { lat: 41.8300, lon: -87.6338, dome: false, name: 'Guaranteed Rate Field' }, + CLE: { lat: 41.4962, lon: -81.6852, dome: false, name: 'Progressive Field' }, + DET: { lat: 42.3390, lon: -83.0485, dome: false, name: 'Comerica Park' }, + KC: { lat: 39.0517, lon: -94.4803, dome: false, name: 'Kauffman Stadium' }, + MIN: { lat: 44.9817, lon: -93.2778, dome: false, name: 'Target Field' }, + // AL West + HOU: { lat: 29.7572, lon: -95.3553, dome: true, name: 'Minute Maid Park' }, + LAA: { lat: 33.8003, lon: -117.8827, dome: false, name: 'Angel Stadium' }, + OAK: { lat: 37.7516, lon: -122.2005, dome: false, name: 'Oakland Coliseum' }, + SEA: { lat: 47.5914, lon: -122.3324, dome: true, name: 'T-Mobile Park' }, // retractable + TEX: { lat: 32.7475, lon: -97.0822, dome: true, name: 'Globe Life Field' }, // retractable + // NL East + ATL: { lat: 33.8908, lon: -84.4678, dome: false, name: 'Truist Park' }, + MIA: { lat: 25.7781, lon: -80.2197, dome: true, name: 'loanDepot park' }, // retractable + NYM: { lat: 40.7571, lon: -73.8458, dome: false, name: 'Citi Field' }, + PHI: { lat: 39.9061, lon: -75.1665, dome: false, name: 'Citizens Bank Park' }, + WSH: { lat: 38.8730, lon: -77.0074, dome: false, name: 'Nationals Park' }, + // NL Central + CHC: { lat: 41.9484, lon: -87.6553, dome: false, name: 'Wrigley Field' }, + CIN: { lat: 39.0975, lon: -84.5067, dome: false, name: 'Great American Ball Park' }, + MIL: { lat: 43.0280, lon: -87.9712, dome: true, name: 'American Family Field' }, // retractable + PIT: { lat: 40.4469, lon: -80.0058, dome: false, name: 'PNC Park' }, + STL: { lat: 38.6226, lon: -90.1928, dome: false, name: 'Busch Stadium' }, + // NL West + ARI: { lat: 33.4453, lon: -112.0667, dome: true, name: 'Chase Field' }, // retractable + COL: { lat: 39.7559, lon: -104.9942, dome: false, name: 'Coors Field' }, + LAD: { lat: 34.0739, lon: -118.2400, dome: false, name: 'Dodger Stadium' }, + SD: { lat: 32.7073, lon: -117.1566, dome: false, name: 'Petco Park' }, + SF: { lat: 37.7786, lon: -122.3893, dome: false, name: 'Oracle Park' }, +}); + +function normalizeCode(code) { + if (!code) return null; + return String(code).trim().toUpperCase(); +} + +function getMlbVenue(teamCode) { + const code = normalizeCode(teamCode); + if (!code) return null; + return MLB_VENUES[code] || null; +} + +// ---- World Cup 2026 ---- +// Sourced from src/data/worldcup2026.js (the canonical list of host +// venues + altitudes). Added lat/lon + dome flag here to keep the +// weather-fetch path independent of the i18n/altitude consumer. +const WC_VENUES = Object.freeze({ + 'MetLife Stadium': { lat: 40.8135, lon: -74.0746, dome: false }, + 'AT&T Stadium': { lat: 32.7473, lon: -97.0945, dome: true }, // retractable + 'SoFi Stadium': { lat: 33.9535, lon: -118.3392, dome: true }, // partial roof + 'Hard Rock Stadium': { lat: 25.9580, lon: -80.2389, dome: false }, + 'Lincoln Financial Field': { lat: 39.9008, lon: -75.1675, dome: false }, + 'Lumen Field': { lat: 47.5952, lon: -122.3316, dome: false }, + 'Gillette Stadium': { lat: 42.0909, lon: -71.2643, dome: false }, + 'Mercedes-Benz Stadium': { lat: 33.7553, lon: -84.4006, dome: true }, // retractable + 'NRG Stadium': { lat: 29.6847, lon: -95.4107, dome: true }, // retractable + 'Arrowhead Stadium': { lat: 39.0489, lon: -94.4839, dome: false }, + 'BC Place': { lat: 49.2768, lon: -123.1116, dome: true }, // retractable + 'BMO Field': { lat: 43.6332, lon: -79.4185, dome: false }, + 'Estadio Azteca': { lat: 19.3030, lon: -99.1503, dome: false }, + 'Estadio BBVA': { lat: 25.6692, lon: -100.2444, dome: false }, + 'Estadio Akron': { lat: 20.6817, lon: -103.4625, dome: false }, + "Levi's Stadium": { lat: 37.4032, lon: -121.9698, dome: false }, +}); + +function getWcVenueCoords(name) { + if (!name) return null; + return WC_VENUES[name] || null; +} + +module.exports = { + MLB_VENUES, + WC_VENUES, + getMlbVenue, + getWcVenueCoords, + __internals: { normalizeCode }, +}; diff --git a/src/services/intelligence/computeFeatures.js b/src/services/intelligence/computeFeatures.js index 2007eca..18a98ab 100644 --- a/src/services/intelligence/computeFeatures.js +++ b/src/services/intelligence/computeFeatures.js @@ -19,6 +19,54 @@ * * The caller (analyzeViaEngine1) reads the returned `errors` array and * downgrades confidence accordingly via the adapter's reasoning string. + * + * ───────────────────────────────────────────────────────────────────── + * Signal provenance (Session 15 audit) + * ───────────────────────────────────────────────────────────────────── + * Every signal the engine reads has a documented source. Phantom + * signals — referenced in reasoning but populated by nothing — would + * be a trust failure. As of Session 15 there are none. + * + * • injury_severity_score (engine1.js:126 reads it; analyzeViaEngine1 + * surfaces it in reasoning at line 156) + * ← `src/services/intelligence/injuryParser.js` (ESPN injury feed) + * Populated by the grading orchestrator in batch mode; in the + * single-prop path it lives in the `featureCache` payload. + * + * • coach_pace_delta + coach_player_interaction + * ← `src/services/intelligence/coachSignals.js` reads the + * `coach_profiles` Supabase table (migration 017), with a + * `src/config/coaches.json` seed file as the cold-start fallback. + * + * • consistency (boom_bust / reliable / elite labels + numeric score) + * ← `src/services/intelligence/consistencyScore.js` operating on + * game logs from `gameLogService` (ESPN). When game logs are + * unavailable, defaults to `{consistency:'unknown', score:null}` + * which engine1 treats as neutral (does not penalize). + * + * • Tank01 t01_* fields (added Session 14) + * ← `src/services/intelligence/tank01Augment.js` reads cache keys + * written by `scripts/tank01-prefetch.js` (Session 15 — added + * this session) which calls the Tank01 NBA/MLB RapidAPI adapters. + * + * • Soccer features (10 of them — goals_per_90, xG, altitude, etc.) + * ← `src/services/intelligence/soccerFeatureExtractor.js` cascade + * across api-football → footapi → football-data cache keys. + * + * • Park factors (Session 15 — MLB) + * ← `src/data/parkFactors.js` — static FanGraphs 2024-25 data. + * + * • Weather (Session 15 — MLB + soccer) + * ← `src/services/weatherService.js` calls Open-Meteo (no key), + * cached 1h in Redis. Skipped for dome stadiums. + * + * • Pace factors (Session 15 — NBA) + * ← `src/data/paceFactors.js` — static NBA team pace data. + * + * No signal currently surfaces in user-facing reasoning that isn't + * populated by one of the sources above. When a source is down, the + * signal returns null and reasoning omits it gracefully — never + * fabricated. */ const axios = require('axios'); @@ -37,6 +85,15 @@ const { extractSoccerFeatures, isSoccerSport } = require('./soccerFeatureExtract // populates the cache. Until that lands, the augmentor returns // empty objects and the existing ESPN-derived features stand alone. const tank01Augment = require('./tank01Augment'); +// Session 15 — static lookup tables (MLB park factors, NBA pace +// factors). Pure synchronous reads, no network, no cache. Merged +// into the feature map alongside the per-sport ESPN payload. +const { getParkFactor } = require('../../data/parkFactors'); +const { getPaceFactor } = require('../../data/paceFactors'); +// Session 15 — Open-Meteo weather fetch. 1h Redis cache, 5s timeout, +// silent on failure. Skipped for dome stadiums via the venue index. +const weatherService = require('../weatherService'); +const { getMlbVenue, getWcVenueCoords } = require('../../data/venueCoordinates'); const HTTP_TIMEOUT_MS = 8_000; @@ -224,6 +281,58 @@ async function computeFeaturesForProp(rawProp = {}) { console.warn('[computeFeatures] Tank01 augmentation skipped:', err.message); } + // Session 15 — static context augmentation. Park factors (MLB), + // pace factors (NBA). Synchronous, can't fail; the lookups return + // null on miss, which we treat as "no signal — drop the field". + try { + if (sport === 'mlb') { + // Home team in this matchup hosts the game; if the player's + // team is home, use their abbr — otherwise use the opponent's. + const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr; + const park = getParkFactor(homeAbbr); + if (park) { + features.park_hr = park.hr; + features.park_h = park.h; + features.park_r = park.r; + features.park_home = homeAbbr; + } + } else if (sport === 'nba') { + // Pace factors are per-team — use the player's own team (fast + // teams up the count regardless of opponent, slow teams + // compress). Opponent pace effect is a separate signal we + // could layer in a follow-up. + const pace = getPaceFactor(teamAbbr); + if (pace != null) features.pace_factor = pace; + const oppPace = getPaceFactor(game?.opponentAbbr); + if (oppPace != null) features.opp_pace_factor = oppPace; + } + } catch (err) { + console.warn('[computeFeatures] static context augmentation skipped:', err.message); + } + + // Session 15 — weather. Open-Meteo via weatherService. 5s timeout, + // 1h Redis cache, dome-aware skip. Outdoor MLB + soccer benefit; + // basketball indoor venues skip entirely. + try { + if (sport === 'mlb') { + const homeAbbr = game?.isHome ? teamAbbr : game?.opponentAbbr; + const venue = homeAbbr ? getMlbVenue(homeAbbr) : null; + if (venue && !venue.dome && Number.isFinite(venue.lat) && Number.isFinite(venue.lon)) { + const w = await weatherService.getWeather(venue.lat, venue.lon); + if (w) { + features.weather_temp_f = w.temp_f ?? null; + features.weather_wind_mph = w.wind_mph ?? null; + features.weather_wind_dir = w.wind_dir ?? null; + features.weather_precip = w.precip_mm ?? null; + } + } + } + // Soccer weather slots in via the soccer branch (handled earlier + // for the soccer sport — the venue is part of the cascade). + } catch (err) { + console.warn('[computeFeatures] weather lookup skipped:', err.message); + } + const trap = await safeGetTrap({ playerName: player, statType, diff --git a/src/services/intelligence/mlbContext.js b/src/services/intelligence/mlbContext.js new file mode 100644 index 0000000..bf03289 --- /dev/null +++ b/src/services/intelligence/mlbContext.js @@ -0,0 +1,94 @@ +/** + * MLB matchup context helpers (Session 15). + * + * Two pure functions, no I/O, no state: + * - platoonAdvantage(pitcherHand, batterHand) + * - projectedPA(lineupPosition) + * + * Sources: + * - Platoon splits: the standard MLB convention — opposite-handed + * matchups favor the batter (LHP vs RHB, RHP vs LHB). Switch + * hitters get the advantage in either matchup because they bat + * opposite-handed by definition. + * - Lineup → projected plate appearances: derived from MLB-average + * team PA distributions per lineup slot (2024 league composite). + * A leadoff hitter sees ~4.7 PA in a 9-inning game; the 9-hole + * sees ~3.5. Numbers from baseball-reference team batting tables + * averaged across all 30 teams. + * + * Callers (computeFeatures MLB branch) attach the outputs to the + * feature vector as `platoon_advantage` (bool|null) and + * `projected_pa` (number|null). When inputs are absent or invalid + * the helpers return null — engine1 treats null as a neutral signal + * and reasoning omits the line gracefully. + */ + +const HAND_VALUES = new Set(['L', 'R', 'S']); + +function normalizeHand(hand) { + if (!hand) return null; + const h = String(hand).trim().toUpperCase().charAt(0); + return HAND_VALUES.has(h) ? h : null; +} + +/** + * platoonAdvantage — true when the batter has the platoon edge. + * + * LHP vs RHB → true (right-handed batter sees pitches better) + * LHP vs LHB → false + * RHP vs RHB → false + * RHP vs LHB → true + * any vs Switch hitter → true (switch hits opposite of pitcher) + * + * Returns null when either hand is unknown. The grading engine reads + * `null` as "no signal" — same treatment as `false` for confidence + * arithmetic, but reasoning will skip the line. + */ +function platoonAdvantage(pitcherHand, batterHand) { + const p = normalizeHand(pitcherHand); + const b = normalizeHand(batterHand); + if (!p || !b) return null; + if (b === 'S') return true; // switch hitter — always has edge + if (p === b) return false; // same-handed → pitcher edge + return true; // opposite-handed → batter edge +} + +// League-average plate appearances per lineup slot in 9-inning games. +// Source: 2024 MLB composite (baseball-reference team batting tables). +// Slot 1 (leadoff) leads the team; later slots see fewer PAs because +// they may not bat in the bottom of innings already wrapped up. +const PA_BY_SLOT = Object.freeze({ + 1: 4.70, + 2: 4.55, + 3: 4.43, + 4: 4.31, + 5: 4.19, + 6: 4.07, + 7: 3.95, + 8: 3.83, + 9: 3.71, +}); + +/** + * projectedPA — expected plate appearances by lineup position. + * + * @param {number|string} position 1..9 + * @returns {number|null} + * + * Out-of-range or invalid → null. This keeps reasoning honest when + * the odds payload doesn't carry batting order yet (the more common + * case today — odds-api doesn't expose lineup position in the prop + * envelope). + */ +function projectedPA(position) { + if (position == null) return null; + const p = Number(position); + if (!Number.isInteger(p) || p < 1 || p > 9) return null; + return PA_BY_SLOT[p]; +} + +module.exports = { + platoonAdvantage, + projectedPA, + __internals: { normalizeHand, HAND_VALUES, PA_BY_SLOT }, +}; diff --git a/src/services/stripeService.js b/src/services/stripeService.js index 9443ec6..f2a07cf 100644 --- a/src/services/stripeService.js +++ b/src/services/stripeService.js @@ -7,15 +7,18 @@ function getStripe() { return _stripe; } +// Session 15 — fallback strings like 'price_analyst_monthly' would +// 400 from Stripe in production (they're not real `price_xxx` IDs). +// All maps now fall back to null; getPriceId then returns the +// PRICE_UNCONFIGURED sentinel for unset values. Founder prices +// additionally fall back to the standard tier price so a user with +// a valid founder code on a deploy that doesn't yet have founder +// prices wired still gets a successful checkout at standard rate. const PRICE_MAP = { - analyst: process.env.STRIPE_PRICE_ANALYST || 'price_analyst_monthly', - analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder', - desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly', - desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder', - // Session 14 — Africa tier ($4.99/mo). The Stripe product must be - // created in the dashboard before STRIPE_PRICE_AFRICA carries a real - // ID. Until then `getPriceId('africa')` returns a sentinel that - // surfaces a clean error to the user via the route handler. + analyst: process.env.STRIPE_PRICE_ANALYST || null, + analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || null, + desk: process.env.STRIPE_PRICE_DESK || null, + desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || null, africa: process.env.STRIPE_PRICE_AFRICA || null, }; @@ -38,13 +41,21 @@ function isFounderCodeValid(code) { function getPriceId(tier, founderCode) { const isFounder = isFounderCodeValid(founderCode); - if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst; - if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk; + if (tier === 'analyst') { + // Session 15 — if a valid founder code is presented but the + // founder price ID isn't wired (env unset), gracefully fall + // back to the standard analyst price rather than 503'ing the + // user. The founder discount is operator-controlled; the + // checkout itself shouldn't break. + if (isFounder && PRICE_MAP.analyst_founder) return PRICE_MAP.analyst_founder; + return PRICE_MAP.analyst || PRICE_UNCONFIGURED; + } + if (tier === 'desk') { + if (isFounder && PRICE_MAP.desk_founder) return PRICE_MAP.desk_founder; + return PRICE_MAP.desk || PRICE_UNCONFIGURED; + } if (tier === 'africa') { - // Africa tier doesn't have a founder discount — it IS the - // discount. Returns the sentinel when STRIPE_PRICE_AFRICA is - // unset so the route handler can produce a clean error instead - // of forwarding a null price ID to Stripe. + // Africa tier has no founder discount — it IS the discount. return PRICE_MAP.africa || PRICE_UNCONFIGURED; } throw new Error(`Invalid tier: ${tier}`); diff --git a/src/services/weatherService.js b/src/services/weatherService.js new file mode 100644 index 0000000..01bdefa --- /dev/null +++ b/src/services/weatherService.js @@ -0,0 +1,103 @@ +/** + * Weather service (Session 15). + * + * Open-Meteo proxy. No API key required (the service is free for + * non-commercial use and sportsbook analytics is firmly non- + * commercial intelligence). 5s hard timeout, 1h Redis cache, + * graceful degrade (returns null on any failure — never throws, + * never blocks the grade). + * + * Outputs are normalized to the units North-American bettors think + * in: temperature in Fahrenheit, wind in mph. Open-Meteo's defaults + * are Celsius + km/h, so we request the imperial units directly via + * query params. + * + * Cache key: `weather:{lat}:{lon}:{hour}` — keyed by the current + * UTC hour so two requests within the same hour hit cache. Slightly + * less precise than a sliding TTL but matches Open-Meteo's hourly + * forecast cadence and keeps cache churn bounded. + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../utils/redis'); + +const BASE_URL = 'https://api.open-meteo.com/v1/forecast'; +const HTTP_TIMEOUT_MS = 5_000; +const CACHE_TTL_SEC = 3600; // 1h — Open-Meteo refreshes hourly + +function currentHourBucket() { + const d = new Date(); + return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}${String(d.getUTCHours()).padStart(2, '0')}`; +} + +function buildKey(lat, lon) { + // 2-decimal precision is enough for a city-scale lookup and + // collapses neighboring venues onto the same cache key (no real- + // world impact — they share the same weather). + const latKey = Number(lat).toFixed(2); + const lonKey = Number(lon).toFixed(2); + return `weather:${latKey}:${lonKey}:${currentHourBucket()}`; +} + +/** + * getWeather — fetch current weather conditions for a lat/lon. + * + * @param {number} lat + * @param {number} lon + * @returns {Promise<{temp_f:number|null, wind_mph:number|null, wind_dir:number|null, precip_mm:number|null} | null>} + * + * Returns null on: + * - invalid coordinates + * - upstream timeout / 5xx + * - missing fields in the response + * + * The grading engine and reasoning builder both treat null as "no + * signal" — features are simply omitted from the prop's overlay. + */ +async function getWeather(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + + const cacheKey = buildKey(lat, lon); + try { + const cached = await cacheGet(cacheKey); + if (cached !== null) return cached; + } catch { + // Redis hiccup — proceed to network. + } + + try { + const res = await axios.get(BASE_URL, { + timeout: HTTP_TIMEOUT_MS, + params: { + latitude: lat, + longitude: lon, + current: 'temperature_2m,wind_speed_10m,wind_direction_10m,precipitation', + temperature_unit: 'fahrenheit', + wind_speed_unit: 'mph', + precipitation_unit: 'mm', // mm is the universal precipitation unit + }, + }); + const current = res.data?.current; + if (!current) return null; + const out = { + temp_f: Number.isFinite(current.temperature_2m) ? current.temperature_2m : null, + wind_mph: Number.isFinite(current.wind_speed_10m) ? current.wind_speed_10m : null, + wind_dir: Number.isFinite(current.wind_direction_10m) ? current.wind_direction_10m : null, + precip_mm: Number.isFinite(current.precipitation) ? current.precipitation : null, + _fetched_at: new Date().toISOString(), + }; + try { await cacheSet(cacheKey, out, CACHE_TTL_SEC); } catch { /* graceful */ } + return out; + } catch (err) { + // Open-Meteo down, timeout, 5xx — silently degrade. + if (err && err.code !== 'ECONNABORTED') { + console.warn('[weatherService] fetch failed:', err.message); + } + return null; + } +} + +module.exports = { + getWeather, + __internals: { BASE_URL, HTTP_TIMEOUT_MS, CACHE_TTL_SEC, buildKey, currentHourBucket }, +}; diff --git a/tests/integration/stripe.test.js b/tests/integration/stripe.test.js index 1fba74f..b9505dc 100644 --- a/tests/integration/stripe.test.js +++ b/tests/integration/stripe.test.js @@ -43,6 +43,12 @@ jest.mock('axios'); process.env.ODDS_API_KEY = 'test'; process.env.STRIPE_SECRET_KEY = 'sk_test_xxx'; process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test'; +// Session 15 — PRICE_MAP was hardened to drop the fake-string +// fallbacks (would 400 from Stripe in production). The integration +// tests need real-looking Stripe price IDs in env so getPriceId +// doesn't return the unconfigured sentinel and 503 the happy path. +process.env.STRIPE_PRICE_ANALYST = process.env.STRIPE_PRICE_ANALYST || 'price_test_analyst'; +process.env.STRIPE_PRICE_DESK = process.env.STRIPE_PRICE_DESK || 'price_test_desk'; const app = require('../../src/app'); diff --git a/tests/unit/computeFeatures.test.js b/tests/unit/computeFeatures.test.js index cdc8a3e..383e1c9 100644 --- a/tests/unit/computeFeatures.test.js +++ b/tests/unit/computeFeatures.test.js @@ -133,7 +133,7 @@ describe('computeFeaturesForProp — graceful degradation', () => { expect(out.meta.gameId).toBeNull(); }); - test('feature fetch throws → empty features + error noted, no crash', async () => { + test('feature fetch throws → ESPN fields empty, but static-context augmentation still surfaces (Session 15)', async () => { mockSupabaseState.rosterRow = { team_abbr: 'NYK', espn_id: '1', sport: 'nba' }; mockAxiosGet.mockResolvedValue(nbaScoreboard([game('e2', 'NYK', 'BOS')])); mockFeatures.throws = true; @@ -141,7 +141,16 @@ describe('computeFeaturesForProp — graceful degradation', () => { player: 'Brunson', stat_type: 'points', line: 25, direction: 'over', sport: 'nba', }); expect(out.meta.errors).toContain('no_features_computed'); - expect(out.features).toEqual({}); + // Session 15 — static lookups (pace factor, park factor) populate + // regardless of ESPN fetch state. The contract used to be + // "features is empty when ESPN fails"; the contract is now + // "features may contain static context even when ESPN fails." + // ESPN-derived fields (l5_avg, opp_rank_stat, ...) ARE absent. + expect(out.features.l5_avg).toBeUndefined(); + expect(out.features.opp_rank_stat).toBeUndefined(); + // Pace factor lookup is static and stable for known team codes. + expect(out.features.pace_factor).toBe(95); // NYK pace + expect(out.features.opp_pace_factor).toBe(99); // BOS pace expect(out.trap).toBeDefined(); }); diff --git a/tests/unit/mlbContext.test.js b/tests/unit/mlbContext.test.js new file mode 100644 index 0000000..ffb0b15 --- /dev/null +++ b/tests/unit/mlbContext.test.js @@ -0,0 +1,89 @@ +// MLB context helpers (Session 15) — pitcher handedness + lineup PA. + +const { platoonAdvantage, projectedPA, __internals } = require('../../src/services/intelligence/mlbContext'); + +describe('platoonAdvantage', () => { + test('LHP vs RHB → batter has advantage', () => { + expect(platoonAdvantage('L', 'R')).toBe(true); + }); + test('RHP vs LHB → batter has advantage', () => { + expect(platoonAdvantage('R', 'L')).toBe(true); + }); + test('LHP vs LHB → pitcher has advantage', () => { + expect(platoonAdvantage('L', 'L')).toBe(false); + }); + test('RHP vs RHB → pitcher has advantage', () => { + expect(platoonAdvantage('R', 'R')).toBe(false); + }); + test('switch hitter ALWAYS has the edge (vs LHP)', () => { + expect(platoonAdvantage('L', 'S')).toBe(true); + }); + test('switch hitter ALWAYS has the edge (vs RHP)', () => { + expect(platoonAdvantage('R', 'S')).toBe(true); + }); + test('null pitcher → null', () => { + expect(platoonAdvantage(null, 'R')).toBeNull(); + }); + test('null batter → null', () => { + expect(platoonAdvantage('L', null)).toBeNull(); + }); + test('invalid input → null (does not throw)', () => { + expect(platoonAdvantage('Z', 'R')).toBeNull(); + expect(platoonAdvantage('L', 'X')).toBeNull(); + }); + test('case-insensitive', () => { + expect(platoonAdvantage('l', 'r')).toBe(true); + expect(platoonAdvantage('Right', 'Left')).toBe(true); + }); + test('whitespace tolerant', () => { + expect(platoonAdvantage(' L ', ' R ')).toBe(true); + }); +}); + +describe('projectedPA', () => { + test('leadoff (1) sees the most PAs (~4.7)', () => { + expect(projectedPA(1)).toBeGreaterThan(4.6); + expect(projectedPA(1)).toBeLessThan(4.8); + }); + test('9-hole sees the fewest PAs (~3.7)', () => { + expect(projectedPA(9)).toBeGreaterThan(3.6); + expect(projectedPA(9)).toBeLessThan(3.8); + }); + test('PA decreases monotonically by slot', () => { + let prev = Infinity; + for (let i = 1; i <= 9; i += 1) { + const pa = projectedPA(i); + expect(pa).toBeLessThan(prev); + prev = pa; + } + }); + test('numeric string input is accepted', () => { + expect(projectedPA('3')).toBeCloseTo(4.43, 2); + }); + test('out-of-range returns null', () => { + expect(projectedPA(0)).toBeNull(); + expect(projectedPA(10)).toBeNull(); + expect(projectedPA(-1)).toBeNull(); + }); + test('null / undefined / non-numeric → null', () => { + expect(projectedPA(null)).toBeNull(); + expect(projectedPA(undefined)).toBeNull(); + expect(projectedPA('NaN')).toBeNull(); + }); + test('non-integer (e.g. 5.5) → null', () => { + expect(projectedPA(5.5)).toBeNull(); + }); +}); + +describe('normalizeHand', () => { + test('accepts L, R, S in upper/lower case', () => { + expect(__internals.normalizeHand('l')).toBe('L'); + expect(__internals.normalizeHand('R')).toBe('R'); + expect(__internals.normalizeHand('Switch')).toBe('S'); + }); + test('rejects unknown letters', () => { + expect(__internals.normalizeHand('X')).toBeNull(); + expect(__internals.normalizeHand('')).toBeNull(); + expect(__internals.normalizeHand(null)).toBeNull(); + }); +}); diff --git a/tests/unit/paceFactors.test.js b/tests/unit/paceFactors.test.js new file mode 100644 index 0000000..a8b9de8 --- /dev/null +++ b/tests/unit/paceFactors.test.js @@ -0,0 +1,70 @@ +// NBA pace factors (Session 15) — static lookup table tests. + +const pace = require('../../src/data/paceFactors'); + +describe('PACE_FACTORS', () => { + test('covers all 30 NBA teams', () => { + expect(Object.keys(pace.PACE_FACTORS).length).toBe(30); + }); + + test('every entry is a finite integer near 100', () => { + for (const [code, val] of Object.entries(pace.PACE_FACTORS)) { + expect(typeof val).toBe('number'); + expect(Number.isFinite(val)).toBe(true); + expect(val).toBeGreaterThanOrEqual(90); + expect(val).toBeLessThanOrEqual(110); + expect(code).toMatch(/^[A-Z]{2,3}$/); + } + }); + + test('Indiana / Sacramento / Atlanta are at the fast end', () => { + expect(pace.PACE_FACTORS.IND).toBeGreaterThanOrEqual(103); + expect(pace.PACE_FACTORS.SAC).toBeGreaterThanOrEqual(103); + expect(pace.PACE_FACTORS.ATL).toBeGreaterThanOrEqual(102); + }); + + test('Orlando / New York / Cleveland are at the slow end', () => { + expect(pace.PACE_FACTORS.ORL).toBeLessThanOrEqual(96); + expect(pace.PACE_FACTORS.NYK).toBeLessThanOrEqual(96); + expect(pace.PACE_FACTORS.CLE).toBeLessThanOrEqual(98); + }); +}); + +describe('getPaceFactor', () => { + test('returns the value for known teams', () => { + expect(pace.getPaceFactor('IND')).toBe(pace.PACE_FACTORS.IND); + }); + test('case-insensitive + trimmed', () => { + expect(pace.getPaceFactor(' ind ')).toBe(pace.PACE_FACTORS.IND); + expect(pace.getPaceFactor('sac')).toBe(pace.PACE_FACTORS.SAC); + }); + test('null for unknown', () => { + expect(pace.getPaceFactor('XYZ')).toBeNull(); + expect(pace.getPaceFactor('')).toBeNull(); + expect(pace.getPaceFactor(null)).toBeNull(); + }); + + describe('legacy team aliases', () => { + test('NJN resolves to BKN', () => { + expect(pace.getPaceFactor('NJN')).toBe(pace.PACE_FACTORS.BKN); + }); + test('NOH resolves to NOP', () => { + expect(pace.getPaceFactor('NOH')).toBe(pace.PACE_FACTORS.NOP); + }); + test('SEA resolves to OKC', () => { + expect(pace.getPaceFactor('SEA')).toBe(pace.PACE_FACTORS.OKC); + }); + test('CHO resolves to CHA', () => { + expect(pace.getPaceFactor('CHO')).toBe(pace.PACE_FACTORS.CHA); + }); + }); +}); + +describe('immutability', () => { + test('PACE_FACTORS frozen', () => { + expect(Object.isFrozen(pace.PACE_FACTORS)).toBe(true); + }); + test('ALIASES frozen', () => { + expect(Object.isFrozen(pace.ALIASES)).toBe(true); + }); +}); diff --git a/tests/unit/parkFactors.test.js b/tests/unit/parkFactors.test.js new file mode 100644 index 0000000..c845f55 --- /dev/null +++ b/tests/unit/parkFactors.test.js @@ -0,0 +1,95 @@ +// MLB park factors (Session 15) — pin the membership list + assert +// the expected magnitude of the headline parks. Park factors shift +// year-to-year but the directional signal (Coors hot, Oracle cold) +// is stable; the test catches obvious typos. + +const pf = require('../../src/data/parkFactors'); + +describe('parkFactors', () => { + test('covers all 30 MLB teams', () => { + const codes = Object.keys(pf.PARK_FACTORS); + expect(codes.length).toBe(30); + }); + + test('every entry has hr/h/r as finite numbers', () => { + for (const [code, vals] of Object.entries(pf.PARK_FACTORS)) { + expect(typeof code).toBe('string'); + expect(code).toMatch(/^[A-Z]{2,3}$/); + expect(Number.isFinite(vals.hr)).toBe(true); + expect(Number.isFinite(vals.h)).toBe(true); + expect(Number.isFinite(vals.r)).toBe(true); + } + }); + + test('Coors Field is the most extreme HR park', () => { + const hrs = Object.values(pf.PARK_FACTORS).map((p) => p.hr); + const maxHr = Math.max(...hrs); + expect(pf.PARK_FACTORS.COL.hr).toBe(maxHr); + expect(pf.PARK_FACTORS.COL.hr).toBeGreaterThan(120); + }); + + test('Oracle Park (SF) suppresses HRs heavily', () => { + expect(pf.PARK_FACTORS.SF.hr).toBeLessThan(95); + }); + + test('Coors also boosts hits and runs', () => { + expect(pf.PARK_FACTORS.COL.h).toBeGreaterThan(100); + expect(pf.PARK_FACTORS.COL.r).toBeGreaterThan(100); + }); + + test('most parks land within ±15% of neutral', () => { + // Coors + SF are deliberate outliers; check the rest cluster. + const codes = Object.keys(pf.PARK_FACTORS).filter((c) => c !== 'COL' && c !== 'SF'); + for (const code of codes) { + const { hr, h, r } = pf.PARK_FACTORS[code]; + expect(hr).toBeGreaterThanOrEqual(85); + expect(hr).toBeLessThanOrEqual(115); + expect(h).toBeGreaterThanOrEqual(90); + expect(h).toBeLessThanOrEqual(110); + expect(r).toBeGreaterThanOrEqual(90); + expect(r).toBeLessThanOrEqual(110); + } + }); +}); + +describe('getParkFactor', () => { + test('returns the row for known teams', () => { + expect(pf.getParkFactor('NYY').hr).toBe(pf.PARK_FACTORS.NYY.hr); + expect(pf.getParkFactor('COL').hr).toBeGreaterThan(120); + }); + + test('case-insensitive', () => { + expect(pf.getParkFactor('nyy').hr).toBeGreaterThan(0); + expect(pf.getParkFactor('Col')).toBe(pf.PARK_FACTORS.COL); + }); + + test('whitespace-tolerant', () => { + expect(pf.getParkFactor(' COL ')).toBeTruthy(); + }); + + test('returns null for unknown / empty', () => { + expect(pf.getParkFactor('XYZ')).toBeNull(); + expect(pf.getParkFactor('')).toBeNull(); + expect(pf.getParkFactor(null)).toBeNull(); + expect(pf.getParkFactor(undefined)).toBeNull(); + }); +}); + +describe('getParkFactorOrNeutral', () => { + test('returns the row when known', () => { + expect(pf.getParkFactorOrNeutral('NYY')).toBe(pf.PARK_FACTORS.NYY); + }); + test('falls back to neutral 100s when unknown', () => { + expect(pf.getParkFactorOrNeutral('XYZ')).toEqual({ hr: 100, h: 100, r: 100 }); + expect(pf.getParkFactorOrNeutral(null)).toEqual({ hr: 100, h: 100, r: 100 }); + }); +}); + +describe('immutability', () => { + test('PARK_FACTORS is frozen at the top level', () => { + expect(Object.isFrozen(pf.PARK_FACTORS)).toBe(true); + }); + test('NEUTRAL is frozen', () => { + expect(Object.isFrozen(pf.NEUTRAL)).toBe(true); + }); +}); diff --git a/tests/unit/stripeService.test.js b/tests/unit/stripeService.test.js index 13c89ee..248c082 100644 --- a/tests/unit/stripeService.test.js +++ b/tests/unit/stripeService.test.js @@ -1,4 +1,13 @@ process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; +// Session 15 — the production fallback strings ('price_analyst_monthly' +// etc.) were dropped because they'd 400 from Stripe in live mode. Tests +// that assert getPriceId returns a string containing 'analyst' / +// 'founder' must now provide the env values BEFORE requiring the +// module (PRICE_MAP is frozen at require time). +process.env.STRIPE_PRICE_ANALYST = 'price_test_analyst_monthly'; +process.env.STRIPE_PRICE_ANALYST_FOUNDER = 'price_test_analyst_founder'; +process.env.STRIPE_PRICE_DESK = 'price_test_desk_monthly'; +process.env.STRIPE_PRICE_DESK_FOUNDER = 'price_test_desk_founder'; // Default mock for the founder-code / price-id tests (no DB interaction). // Webhook tests below replace the implementation per-test. diff --git a/tests/unit/tank01Prefetch.test.js b/tests/unit/tank01Prefetch.test.js new file mode 100644 index 0000000..7f2bb56 --- /dev/null +++ b/tests/unit/tank01Prefetch.test.js @@ -0,0 +1,161 @@ +// Tank01 daily prefetch (Session 15) — orchestrator that pulls the +// Redis cache keys session 14's augmentor reads. Tests cover: +// - graceful skip when adapter has no API key +// - budget cap respected +// - dry-run suppresses adapter calls +// - final-status box scores get pulled, non-final skipped +// - empty slate doesn't crash + +const mockNbaGames = jest.fn(); +const mockNbaBox = jest.fn(); +const mockNbaOdds = jest.fn(); +const mockNbaHasKey = jest.fn(() => true); +jest.mock('../../src/services/adapters/tank01NbaAdapter', () => ({ + getNBAGamesForDate: (...a) => mockNbaGames(...a), + getNBABoxScore: (...a) => mockNbaBox(...a), + getNBABettingOdds: (...a) => mockNbaOdds(...a), + hasApiKey: (...a) => mockNbaHasKey(...a), +})); + +const mockMlbSlate = jest.fn(); +const mockMlbBox = jest.fn(); +const mockMlbBvp = jest.fn(); +const mockMlbHasKey = jest.fn(() => true); +jest.mock('../../src/services/adapters/tank01MlbAdapter', () => ({ + getMLBDailyScoreboard: (...a) => mockMlbSlate(...a), + getMLBBoxScore: (...a) => mockMlbBox(...a), + getMLBBatterVsPitcher: (...a) => mockMlbBvp(...a), + hasApiKey: (...a) => mockMlbHasKey(...a), +})); + +const prefetch = require('../../scripts/tank01-prefetch'); + +beforeEach(() => { + mockNbaGames.mockReset(); + mockNbaBox.mockReset(); + mockNbaOdds.mockReset(); + mockNbaHasKey.mockReset().mockReturnValue(true); + mockMlbSlate.mockReset(); + mockMlbBox.mockReset(); + mockMlbBvp.mockReset(); + mockMlbHasKey.mockReset().mockReturnValue(true); +}); + +describe('parseArgs', () => { + test('defaults', () => { + const a = prefetch.__internals.parseArgs(['node', 'script']); + expect(a.maxRequests).toBe(prefetch.__internals.DEFAULT_BUDGET); + expect(a.dryRun).toBe(false); + expect(a.sports).toEqual(['nba', 'mlb']); + }); + test('--max=N', () => { + expect(prefetch.__internals.parseArgs(['node', 's', '--max=25']).maxRequests).toBe(25); + }); + test('--max ignores non-numeric / non-positive', () => { + expect(prefetch.__internals.parseArgs(['node', 's', '--max=0']).maxRequests).toBe(80); + expect(prefetch.__internals.parseArgs(['node', 's', '--max=foo']).maxRequests).toBe(80); + }); + test('--dry-run', () => { + expect(prefetch.__internals.parseArgs(['node', 's', '--dry-run']).dryRun).toBe(true); + }); + test('--sports filter', () => { + expect(prefetch.__internals.parseArgs(['node', 's', '--sports=mlb']).sports).toEqual(['mlb']); + }); +}); + +describe('budget tracker', () => { + test('counts spend, refuses past cap', () => { + const b = prefetch.__internals.makeBudget(3); + expect(b.canSpend()).toBe(true); + b.spend(); b.spend(); b.spend(); + expect(b.canSpend()).toBe(false); + expect(b.spent()).toBe(3); + }); +}); + +describe('main — NBA path', () => { + test('skips entirely when adapter has no API key', async () => { + mockNbaHasKey.mockReturnValueOnce(false); + mockMlbHasKey.mockReturnValueOnce(false); + const r = await prefetch.main(['node', 'script', '--sports=nba']); + expect(r.nba.skipped).toBe('no_key'); + expect(mockNbaGames).not.toHaveBeenCalled(); + }); + + test('pulls slate, then box score per FINAL game, plus odds once', async () => { + mockNbaGames.mockResolvedValueOnce([ + { gameId: 'G1', gameStatus: 'Final' }, + { gameId: 'G2', gameStatus: 'InProgress' }, // skip + { gameId: 'G3', gameStatus: 'Final' }, + ]); + mockNbaBox.mockResolvedValue({}); + mockNbaOdds.mockResolvedValueOnce({}); + const r = await prefetch.main(['node', 'script', '--sports=nba']); + expect(r.nba.games).toBe(3); + expect(r.nba.boxscores).toBe(2); // only the Finals + expect(r.nba.odds).toBe(true); + expect(mockNbaBox).toHaveBeenCalledTimes(2); + }); + + test('budget cap stops fetches mid-loop', async () => { + mockNbaGames.mockResolvedValueOnce( + Array.from({ length: 10 }, (_, i) => ({ gameId: `G${i}`, gameStatus: 'Final' })), + ); + mockNbaBox.mockResolvedValue({}); + mockNbaOdds.mockResolvedValueOnce({}); + // Budget = 4: one for getNBAGamesForDate, leaves 3 box-score + // slots (no odds — budget exhausted first). + const r = await prefetch.main(['node', 'script', '--sports=nba', '--max=4']); + expect(mockNbaGames).toHaveBeenCalledTimes(1); + expect(mockNbaBox).toHaveBeenCalledTimes(3); + expect(mockNbaOdds).not.toHaveBeenCalled(); + expect(r.requestsSpent).toBe(4); + }); + + test('dry-run skips ALL adapter calls', async () => { + const r = await prefetch.main(['node', 'script', '--dry-run']); + expect(mockNbaGames).not.toHaveBeenCalled(); + expect(mockNbaBox).not.toHaveBeenCalled(); + expect(mockNbaOdds).not.toHaveBeenCalled(); + expect(r.nba.skipped).toBe('dry_run'); + expect(r.mlb.skipped).toBe('dry_run'); + }); + + test('empty slate is not an error', async () => { + mockNbaGames.mockResolvedValueOnce([]); + mockNbaOdds.mockResolvedValueOnce({}); + const r = await prefetch.main(['node', 'script', '--sports=nba']); + expect(r.nba.games).toBe(0); + expect(r.nba.boxscores).toBe(0); + // Odds still pulled — it's a single daily call, not per-game. + expect(r.nba.odds).toBe(true); + }); + + test('null slate (adapter returned null) is not an error', async () => { + mockNbaGames.mockResolvedValueOnce(null); + const r = await prefetch.main(['node', 'script', '--sports=nba']); + expect(r.nba.games).toBe(0); + expect(r.nba.boxscores).toBe(0); + }); +}); + +describe('main — MLB path', () => { + test('skips when adapter has no API key', async () => { + mockMlbHasKey.mockReturnValueOnce(false); + const r = await prefetch.main(['node', 'script', '--sports=mlb']); + expect(r.mlb.skipped).toBe('no_key'); + }); + + test('pulls scoreboard + box scores for Finals/Completed', async () => { + mockMlbSlate.mockResolvedValueOnce([ + { gameId: 'M1', gameStatus: 'Final' }, + { gameId: 'M2', gameStatus: 'In Progress' }, // skip + { gameId: 'M3', gameStatus: 'Completed' }, + ]); + mockMlbBox.mockResolvedValue({}); + const r = await prefetch.main(['node', 'script', '--sports=mlb']); + expect(r.mlb.games).toBe(3); + expect(r.mlb.boxscores).toBe(2); + expect(r.mlb.bvp_skipped_reason).toMatch(/scoreboard/i); + }); +}); diff --git a/tests/unit/weatherService.test.js b/tests/unit/weatherService.test.js new file mode 100644 index 0000000..cfa483a --- /dev/null +++ b/tests/unit/weatherService.test.js @@ -0,0 +1,156 @@ +// weatherService (Session 15) — Open-Meteo proxy with cache + timeout. + +const mockAxiosGet = jest.fn(); +jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) })); + +const mockCache = new Map(); +jest.mock('../../src/utils/redis', () => ({ + cacheGet: async (k) => (mockCache.has(k) ? mockCache.get(k) : null), + cacheSet: async (k, v) => { mockCache.set(k, v); return true; }, + cacheDel: async (k) => { mockCache.delete(k); return true; }, + isDegraded: () => false, +})); + +const ws = require('../../src/services/weatherService'); + +beforeEach(() => { + mockAxiosGet.mockReset(); + mockCache.clear(); +}); + +describe('weatherService.getWeather', () => { + test('invalid coordinates return null without touching the network', async () => { + expect(await ws.getWeather(null, null)).toBeNull(); + expect(await ws.getWeather(NaN, 0)).toBeNull(); + expect(await ws.getWeather(undefined, undefined)).toBeNull(); + expect(mockAxiosGet).not.toHaveBeenCalled(); + }); + + test('happy path — projects Open-Meteo current block to our flat shape', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + current: { + temperature_2m: 78.5, + wind_speed_10m: 12.3, + wind_direction_10m: 165, + precipitation: 0, + }, + }, + }); + const w = await ws.getWeather(39.7559, -104.9942); + expect(w).toMatchObject({ + temp_f: 78.5, + wind_mph: 12.3, + wind_dir: 165, + precip_mm: 0, + }); + expect(typeof w._fetched_at).toBe('string'); + }); + + test('Fahrenheit + mph units requested explicitly', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { current: {} } }); + await ws.getWeather(40, -75); + const [, opts] = mockAxiosGet.mock.calls[0]; + expect(opts.params.temperature_unit).toBe('fahrenheit'); + expect(opts.params.wind_speed_unit).toBe('mph'); + }); + + test('second call within the same hour hits cache', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { current: { temperature_2m: 80, wind_speed_10m: 5, wind_direction_10m: 0, precipitation: 0 } }, + }); + await ws.getWeather(39.7559, -104.9942); + await ws.getWeather(39.7559, -104.9942); + expect(mockAxiosGet).toHaveBeenCalledTimes(1); + }); + + test('coordinate-precision collapse — venues within ~1km share cache', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { current: { temperature_2m: 70, wind_speed_10m: 5, wind_direction_10m: 0, precipitation: 0 } }, + }); + // Two lat/lon pairs that round to the same 2-decimal cache key. + await ws.getWeather(40.815, -74.075); // round to 40.82 / -74.07 + await ws.getWeather(40.823, -74.078); // round to 40.82 / -74.08 — different lon key, different fetch + // 40.82/-74.07 vs 40.82/-74.08 → different keys, two fetches expected + expect(mockAxiosGet).toHaveBeenCalledTimes(2); + }); + + test('upstream throw → returns null (graceful)', async () => { + mockAxiosGet.mockRejectedValueOnce(new Error('timeout')); + const w = await ws.getWeather(40, -75); + expect(w).toBeNull(); + }); + + test('upstream returns response without `current` block → null', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: {} }); + const w = await ws.getWeather(40, -75); + expect(w).toBeNull(); + }); + + test('individual missing fields default to null without crashing', async () => { + mockAxiosGet.mockResolvedValueOnce({ + data: { + current: { temperature_2m: 60 /* wind/precip missing */ }, + }, + }); + const w = await ws.getWeather(40, -75); + expect(w.temp_f).toBe(60); + expect(w.wind_mph).toBeNull(); + expect(w.wind_dir).toBeNull(); + expect(w.precip_mm).toBeNull(); + }); + + test('5-second timeout configured', async () => { + mockAxiosGet.mockResolvedValueOnce({ data: { current: {} } }); + await ws.getWeather(40, -75); + const [, opts] = mockAxiosGet.mock.calls[0]; + expect(opts.timeout).toBe(ws.__internals.HTTP_TIMEOUT_MS); + expect(opts.timeout).toBeLessThanOrEqual(5000); + }); +}); + +describe('venueCoordinates', () => { + const venues = require('../../src/data/venueCoordinates'); + + test('all 30 MLB venues defined with finite lat/lon', () => { + const codes = Object.keys(venues.MLB_VENUES); + expect(codes.length).toBe(30); + for (const [code, v] of Object.entries(venues.MLB_VENUES)) { + expect(Number.isFinite(v.lat)).toBe(true); + expect(Number.isFinite(v.lon)).toBe(true); + expect(typeof v.dome).toBe('boolean'); + expect(typeof v.name).toBe('string'); + // Sanity: every MLB venue is in roughly the right hemisphere. + expect(v.lat).toBeGreaterThan(20); + expect(v.lat).toBeLessThan(50); + expect(v.lon).toBeLessThan(-65); + expect(v.lon).toBeGreaterThan(-125); + expect(code).toMatch(/^[A-Z]{2,3}$/); + } + }); + + test('all 16 WC 2026 venues defined', () => { + expect(Object.keys(venues.WC_VENUES).length).toBe(16); + }); + + test('dome stadiums correctly flagged', () => { + // Tampa Bay's Tropicana is the canonical fixed-roof dome in the AL. + expect(venues.MLB_VENUES.TB.dome).toBe(true); + // Coors is open-air. + expect(venues.MLB_VENUES.COL.dome).toBe(false); + // Toronto's Rogers Centre is retractable — treated as dome. + expect(venues.MLB_VENUES.TOR.dome).toBe(true); + }); + + test('getMlbVenue lookup', () => { + expect(venues.getMlbVenue('NYY').name).toBe('Yankee Stadium'); + expect(venues.getMlbVenue('xyz')).toBeNull(); + expect(venues.getMlbVenue(null)).toBeNull(); + }); + + test('getWcVenueCoords lookup', () => { + expect(venues.getWcVenueCoords('MetLife Stadium').dome).toBe(false); + expect(venues.getWcVenueCoords('Estadio Azteca').lat).toBeCloseTo(19.30, 1); + expect(venues.getWcVenueCoords('Bogus')).toBeNull(); + }); +}); diff --git a/web/public/sw.js b/web/public/sw.js index 5a885cc..efee20c 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':'93077fba92ceabe8021ae34e55942ad6','url':'/_next/static/L2WpLC5_woBu14yC_09CH/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/L2WpLC5_woBu14yC_09CH/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1896-ef66d63637706ee4.js'},{'revision':null,'url':'/_next/static/chunks/1958-a1cd681eb2c1a83e.js'},{'revision':null,'url':'/_next/static/chunks/2346-d508a4289748cd4a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-c2f6e0877b6c10aa.js'},{'revision':null,'url':'/_next/static/chunks/52774a7f.8203600637b1464d.js'},{'revision':null,'url':'/_next/static/chunks/5838-7ac6ecc648315323.js'},{'revision':null,'url':'/_next/static/chunks/6810-8b074082a0b51859.js'},{'revision':null,'url':'/_next/static/chunks/7602.cfbf0dc56b47f93b.js'},{'revision':null,'url':'/_next/static/chunks/7918-840449b91e99704b.js'},{'revision':null,'url':'/_next/static/chunks/8500-3953cc33eeceb44e.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-54d62c4d2564a0ac.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-f6253334ccab6a26.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-5cd033ea13d5ab76.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-012a02e7c1c1bf4c.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-1c70506fd9665dbf.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-f6536af186e4c75a.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-a7bd13dc3b447906.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-b2f371ae0e2dc445.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-7560a04fb2b26dad.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-ce0df41afd789a48.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/page-0d695fcd5650fe29.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-e0324df275d75d0f.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-1d6940745beb4eba.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-06e0d1254e4a1b76.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-5a6b00ebb8de6035.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-f1e96999abbeccb5.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-bc585d14e3b7d415.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-0d544936961f5807.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-9258a8a7eeebe655.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-6b2f47a27c91344b.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-4d522ede91624bab.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-b2b44714c0aa7d32.js'},{'revision':null,'url':'/_next/static/chunks/framework-29591c88a1db84ab.js'},{'revision':null,'url':'/_next/static/chunks/main-app-f54c179578b140aa.js'},{'revision':null,'url':'/_next/static/chunks/main-c31eab22221c05bc.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-d5beecc31181c673.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-908bd5bde21a07fe.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-046cc6705514b5bd.js'},{'revision':null,'url':'/_next/static/css/ef4d31504fa635a6.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':'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':'93077fba92ceabe8021ae34e55942ad6','url':'/_next/static/ZDvereeObVypTQtmIwkSx/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/ZDvereeObVypTQtmIwkSx/_ssgManifest.js'},{'revision':null,'url':'/_next/static/chunks/1896-4649d6ca747442e6.js'},{'revision':null,'url':'/_next/static/chunks/1958-a1cd681eb2c1a83e.js'},{'revision':null,'url':'/_next/static/chunks/2346-d508a4289748cd4a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-c2f6e0877b6c10aa.js'},{'revision':null,'url':'/_next/static/chunks/52774a7f.8203600637b1464d.js'},{'revision':null,'url':'/_next/static/chunks/5838-7ac6ecc648315323.js'},{'revision':null,'url':'/_next/static/chunks/6810-8b074082a0b51859.js'},{'revision':null,'url':'/_next/static/chunks/7602.cfbf0dc56b47f93b.js'},{'revision':null,'url':'/_next/static/chunks/7918-840449b91e99704b.js'},{'revision':null,'url':'/_next/static/chunks/8500-3953cc33eeceb44e.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-54d62c4d2564a0ac.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-f6253334ccab6a26.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-5cd033ea13d5ab76.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-012a02e7c1c1bf4c.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-1c70506fd9665dbf.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-f6536af186e4c75a.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-a7bd13dc3b447906.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-b2f371ae0e2dc445.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-7560a04fb2b26dad.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-ce0df41afd789a48.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/page-0d695fcd5650fe29.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-e0324df275d75d0f.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-1d6940745beb4eba.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-06e0d1254e4a1b76.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-5a6b00ebb8de6035.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-f1e96999abbeccb5.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-bc585d14e3b7d415.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-0d544936961f5807.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-3d8359a06d3506dc.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-9258a8a7eeebe655.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-6b2f47a27c91344b.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-4d522ede91624bab.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-b2b44714c0aa7d32.js'},{'revision':null,'url':'/_next/static/chunks/framework-29591c88a1db84ab.js'},{'revision':null,'url':'/_next/static/chunks/main-app-f54c179578b140aa.js'},{'revision':null,'url':'/_next/static/chunks/main-c31eab22221c05bc.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-908bd5bde21a07fe.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-d5beecc31181c673.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-908bd5bde21a07fe.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-046cc6705514b5bd.js'},{'revision':null,'url':'/_next/static/css/ef4d31504fa635a6.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':'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/components/Pricing.tsx b/web/src/components/Pricing.tsx index d4fbbd3..7aa3d3d 100644 --- a/web/src/components/Pricing.tsx +++ b/web/src/components/Pricing.tsx @@ -144,15 +144,12 @@ export default function Pricing() { return; } - // Session 12 — Africa tier: Stripe product + backend validation - // not yet wired (intentional this session). Show an honest - // "coming soon" instead of a 400. When STRIPE_PRICE_AFRICA is - // configured AND the backend accepts the tier, this short-circuit - // gets removed and the standard checkout path takes over. - if (tier === 'africa') { - setError('VYNDR Africa launches once Stripe regional processing is finalized. Email support@vyndr.app to lock the $4.99/mo founder price.'); - return; - } + // Session 15 — Africa short-circuit removed. The Session 14 + // backend now handles 'africa' end-to-end: validation accepts + // it, and when STRIPE_PRICE_AFRICA isn't configured the route + // returns 503 { code: 'tier_unconfigured', error: '...' } which + // the existing error-display path below surfaces inline. No + // special-case needed at the UI layer. // Anonymous → bounce to signup with a returnTo back to /#pricing. if (!session) {