diff --git a/BACKEND_HANDOFF.md b/BACKEND_HANDOFF.md new file mode 100644 index 0000000..9a005f7 --- /dev/null +++ b/BACKEND_HANDOFF.md @@ -0,0 +1,140 @@ +# VYNDR — Backend / Frontend Data Contract (BACKEND_HANDOFF.md) + +**Canonical frontend↔backend data contract for the Player Intelligence System.** +Every future session references this. Authored Session 44 from the shipped +implementation (Sessions 42–44). When an endpoint or component shape changes, +update this file in the same commit. + +> Naming: archetypes are **VYNDR Originals** (TORCH, BOMBER, ALPHA, …). The old +> descriptive labels live on each archetype as `legacyName` and are NEVER shown. + +--- + +## 1. Archetypes (`src/services/archetypeService.js`) + +`classify(sport, stats) → { sport, primary, secondary|null, blend }` + +``` +primary | secondary : { + name, legacyName, tag, sport, color (#hex), glyph (key), + description, propDNA: { reliable: string[], volatile: string[] }, education +} +blend : [{ archetype: , weight: 0..1 }] // normalized, top 4, sums ~1 +``` + +**Roster (41):** see `ARCHETYPES` registry. Colors are unique *within* a sport, +reused *across* sports. Frontend visual map (color/glyph/desc) is mirrored in +`web/src/lib/archetypes.js` — keep colors identical (a test enforces it). + +- **NBA (15):** TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT, + CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON, + SENTINEL, IGNITER +- **WNBA-unique (5):** DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR (plus reused NBA) +- **MLB (15):** BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX, + WORKHORSE, CATALYST, MIRROR, HAMMER, SINKER, BRIDGE, SWITCH +- **Soccer (6):** FINISHER, MAESTRO, TOWER, MOTOR, BLADE, WALL + +Classifier input shapes: +- NBA/WNBA: `{ ppg, rpg, apg, bpg, spg, threes, usg, fg3a, pos, bench }` +- MLB hitter: `{ avg, hr, rbi, sb, ops, runs, k_rate, doubles }` +- MLB pitcher: `{ era, k9, whip, ip_per_start, saves, role: 'SP'|'RP'|'CL' }` + +--- + +## 2. Stats API (`src/routes/stats.js`, mounted at `/api/stats`) + +All player-intelligence endpoints are public, rate-limited 60/min, and ALWAYS +return 200 with a valid (possibly empty) shape — the UI must never hard-fail. +Browser must hit the **Next proxy** under `web/src/app/api/stats/...`, never +Express directly. + +### `GET /api/stats/player/:name?sport=nba` +``` +{ + player, sport, team, found: boolean, + archetype: { primary, secondary, blend }, // §1 + propDNA: { reliable, volatile }, + education: string, + season: [{ k, v, lg? }], // display rows (mono) + last10: [{ d, opp, res?, stat }], + splits: [{ k, a, b }], + gradeHistory: [{ grade, prop, hit?, miss? }], + activeProps: [{ stat, line, side: 'O'|'U', grade, confidence }], + intel: [{ label, kind: 'form'|'grade'|'plain', value, score?, color }], + injury: { label, note, cascade } | null +} +``` +`found` is true when real season stats OR tonight's graded props exist. MLB stats +come from `mlbStatsAdapter.getPlayerStats(name)`; NBA/WNBA from `nbaStatsClient` +(degrades to `found:false` when the Python service is offline). + +### `GET /api/stats/leaders?sport=mlb&stat=hits&limit=10` +``` +{ sport, stat|null, leaders: [{ player, team, stat, line, side, grade, confidence }] } +``` +Source: the `grades:{sport}` cache (tonight's graded slate, by confidence desc). + +### `GET /api/stats/game/:id?sport=nba` +ESPN game summary (injuries, leaders, ESPN Bet odds, box score) or `{error}`. + +### `GET /api/stats/lineup/:team?sport=mlb` (Session 43) +`{ sport, team, lineup: [{ player, position, battingOrder, projectedMinutes }] }` +MLB returns the probable starting pitcher from the schedule. + +### `GET /api/stats/depth/:team?sport=nba` +`{ sport, team, positions: [{ position, starter, backup, thirdString }] }` + +### `GET /api/stats/cascade/:player?sport=nba&team=SA` +`{ sport, player, cascade: [{ player, stat, delta: '+x%', reason }] }` +Usage redistribution weighted by archetype (SURGE benefits most). + +--- + +## 3. Grade Result Card (`web/src/components/vyndr/GradeResultCard.tsx`) + +`GradeResultData` (built by `web/src/lib/gradeAdapter.js` `mapScanToGradeResult`): +core fields (player, team, sport, stat, line, side, grade, confidence, edge, +projection, signals[], killConditions[], books[], altLadder[]) **plus** optional +Player-Intelligence fields that self-hide when absent: +``` +archetypeBlend?: [{ archetype, weight }] +propDNA?: { reliable: string[], volatile: string[] } +statContext?: { season?, last10?, vsOpp? } +vyndrIntel?: { form?, usage?, matchup?, rest? } +``` +The engine (`analyzeViaEngine1`) attaches snake_case fields +(`season_avg`/`last10_avg`/`form`/`usage`/`matchup_grade`/`rest`, and when a +season line is available, `archetype`/`archetype_blend`/`prop_dna`); the adapter +maps them in. Archetype at grade time requires a multi-stat season line — until +the snapshot pipeline supplies it, the archetype strip stays hidden. + +--- + +## 4. Game Card (`web/src/components/vyndr/GameCard.tsx`) + +`GameCardData` core (id, sport, away/home, time, venue, lines[], …) **plus**: +``` +playerStrips?: [{ player, team, archetype?: {primary, secondary?}, stats: [{label,value}], props: [{stat,line,side,grade}] }] +pitchers?: { away: {name, era, archetype?}, home: {name, era, archetype?} } +``` +Built by `slateAdapter.mapScheduleToGameCards` (`groupPropsByPlayer` + +`mapPitchers`). The card prefers `playerStrips` (name once, horizontal) over +legacy per-prop rows. Book chips use `web/src/lib/books.js` brand colors. + +--- + +## 5. Stat Strip (`web/src/components/vyndr/StatStrip.tsx`) + +`compact` (game cards) | `expanded` (profile hero / grade result). HARD RULE: the +player name appears ONCE; stats flow horizontally in JetBrains Mono. Props render +inline with `GradeBadge`. `onPlayerClick` → `/player/:name?sport=` (`lib/playerHref.js`). + +--- + +## 6. Freshness & caching + +- Schedule cache TTL ≤ 30 min (`scheduleService`). Frontend filters completed + games older than 24h out of the slate (`slateAdapter.isRelevantGame`). +- Player props: PropLine primary (3-key rotation), The-Odds-API backup. +- `grades:{sport}` cache (TTL 2h) is written by `gradeSlateService` on a fresh + odds fetch; it feeds `/leaders` + the player card's `activeProps`. diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 18dac2f..a010aae 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,8 +4,62 @@ 2026-06-18 ## Current Phase -SHIP BUILD v43.0 — Data pipeline wiring + P0 audit fixes + depth chart -foundation. The Session-42 Player Intelligence architecture now flows REAL data. +SHIP BUILD v44.0 — Make it visible: VYNDR Original archetype names, grade-card +intel wiring, stale-game filtering, landing copy, depth proxies. Frontend + +wiring only (no new backend services). + +## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE + +Wired existing backend work into the pages users see + renamed archetypes to +VYNDR Originals. Backend 2045 → **2061 tests** (+16), 167 suites. Web build clean +(exit 0). `BACKEND_HANDOFF.md` added as the canonical data contract. + +### VYNDR Original archetype rename (proprietary names) +All 41 archetypes renamed in `archetypeService.js` + `lib/archetypes.js` + +`ArchetypeBadge`, each keeping `legacyName`/`legacy` (never displayed; resolves +for stale data). NBA: TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT, +CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON, +SENTINEL, IGNITER. WNBA-unique: DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR. MLB: +BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX, WORKHORSE, CATALYST, +MIRROR, HAMMER, SINKER, BRIDGE, SWITCH. Soccer: FINISHER, MAESTRO, TOWER, MOTOR, +BLADE, WALL. **MLB note:** the old taxonomy had two power hitters (POWER +PULL/POWER SLUGGER) but the new set has one power name (BOMBER), so BOMBER now +fires for any high-HR bat (Judge → BOMBER per the audit checklist) and the freed +slot became a real WHIFF strikeout-artist pitcher (improves pitcher coverage). +`getArchetype`/`archetypeInfo` resolve legacy names → VYNDR Originals. + +### Phase 2 — grade-card intel now populates (the real bug) +The chain engine→tierGating→/api/scan proxy already PRESERVED the intel fields +(all spread `...result`/`...data`). The ONE broken link: `scan/page.tsx` called +`mapScanToGradeResult` with a hardcoded field subset and DROPPED season_avg/ +form/usage/matchup_grade/etc. Now forwards them (+ extended `ScanResponse`), so +STAT CONTEXT + VYNDR INTELLIGENCE sections light up on a real MLB grade. + +### Phase 3 — stale-game filtering +`slateAdapter.isRelevantGame(game, now)`: upcoming/live always show; a COMPLETED +game is dropped once >24h old (no more 5-day-old FINALs). Applied in `Slate`'s +`filteredGames`. Schedule TTL was already 60s (≤30min) — no change. + +### Phase 4 — landing copy +`Features.tsx` rewritten: no more "Point-biserial", "Zone 14", "ABS +intelligence", "Auto-calibrating", "Phi-coefficient". User-facing benefit copy +(Player DNA archetypes / Self-improving model / Lineup intel before tip-off / +Deep pitcher-batter matchups). + +### Phase 5 — depth chart proxies (404 fix) +Added the missing Next proxies `/api/stats/lineup/[team]`, `/depth/[team]`, +`/cascade/[player]` → return JSON, not 404. + +### Phase 1 — GameCard swap DEFERRED (decision: Kev) +The live Slate keeps the legacy "Read"/on-demand grade card as a **temporary +bridge**. The vyndr/GameCard swap lands WITH the snapshot pipeline (next +session): the on-demand "Read" model is being retired for a pre-graded snapshot +model (full slate graded at scheduled intervals, grades locked to the line), and +the new card is designed for that. Swapping now would remove grading + show blank +cards (grades cache unpopulated). BookChip brand colors already render in the +legacy card (S43). + +## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES ## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES diff --git a/CLAUDE.md b/CLAUDE.md index abc9474..51489b4 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -386,6 +386,33 @@ Built from the Claude Design "VYNDR Player Intelligence" bundle. `matchesTeam` must guard empty names (`t.includes('')` is always true) — and the schedule `find` checks `g.home`/`g.away`, not the game object. +## VYNDR Original Archetypes + Make-It-Visible (Session 44 — non-obvious) +- **Archetype names are VYNDR Originals** (TORCH/BOMBER/ALPHA/…), NOT the old + descriptive labels. The registry keys in `archetypeService.js` + the map keys + in `lib/archetypes.js` are the new names; each carries `legacyName`/`legacy` + (the old label, NEVER displayed). `getArchetype()` + `archetypeInfo()` + + `badgeStyle()` resolve legacy names → the canonical VYNDR name (defends stale + cached data). The `classify()` scorer objects use the new keys too — if you + edit a threshold, use the new key. Full mapping is in `BACKEND_HANDOFF.md`. +- **MLB power merge:** there's ONE power archetype (BOMBER) — it fires for any + high-HR bat (incl. high-K sluggers like Judge → BOMBER). The old POWER PULL + slot became WHIFF (strikeout-artist pitcher). Don't re-split power. +- **`BACKEND_HANDOFF.md`** (repo root) is the canonical frontend↔backend data + contract — update it in the same commit when an endpoint/component shape changes. +- **Grade-card intel gotcha:** the engine→tierGating→/api/scan chain already + preserves the intel fields (everything spreads `...result`/`...data`). The bug + was `scan/page.tsx` passing a hardcoded field subset to `mapScanToGradeResult` + — it must forward season_avg/last10_avg/form/usage/matchup_grade/rest/archetype + /archetype_blend/prop_dna (and `ScanResponse` must type them) or the card + sections stay hidden. +- **Stale games:** `slateAdapter.isRelevantGame(game, now)` drops completed games + >24h old; `Slate.filteredGames` applies it. Schedule TTL is 60s. +- **GameCard swap is DEFERRED** (Kev's call): the live Slate keeps the legacy + on-demand "Read" card as a bridge until the snapshot pipeline lands. The + on-demand grade model is being retired for a pre-graded snapshot model; the + vyndr/GameCard (playerStrips/pitchers) is built for that and swaps in then. + Don't swap it before the grades cache is populated (cards would be blank). + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js index 73d81d6..ae477c5 100644 --- a/src/services/archetypeService.js +++ b/src/services/archetypeService.js @@ -1,334 +1,324 @@ /** - * archetypeService — player archetype classification (Session 42). + * archetypeService — VYNDR player archetype classification (Session 42; renamed + * to VYNDR Originals in Session 44). * * Pure logic: categorize a player by their prop-behavior pattern from season * averages + usage. Returns a PRIMARY archetype, an optional SECONDARY, and a * weighted `blend` (the production-DNA bar in the design's player profile). * - * The archetype NAMES + colors + glyph keys are the contract shared with the - * frontend `ArchetypeBadge` (web/src/components/vyndr/ArchetypeBadge.tsx) and - * `ArchetypeData` (web/src/lib/archetypes.ts) — ported verbatim from the - * "VYNDR Player Intelligence" design (ArchetypeBadge.dc.html MAP). Keep the - * hex/glyph in sync across all three. + * The archetype NAMES are VYNDR-proprietary (TORCH, BOMBER, ALPHA, …). Each + * carries `legacyName` (the old descriptive label) for reference — never shown. + * colors + glyph keys are the contract shared with the frontend `ArchetypeBadge` + * (web/src/components/vyndr/ArchetypeBadge.tsx) + `archetypes.js`. Keep all three + * in sync. * - * No API calls, no I/O — feed it a stat object, get a classification. That's - * why it lives in services/ and is unit-testable in isolation. + * No API calls, no I/O — feed it a stat object, get a classification. */ // ── Archetype registry ────────────────────────────────────────────── -// color + glyph match the design's badge map. propDNA = which props this -// archetype makes reliable vs volatile. education = the profile's "what does -// this mean" copy. const ARCHETYPES = { // ───────── NBA (15) ───────── - 'VOLUME SCORER': { - tag: 'VOL SCORER', sport: 'nba', color: '#FF6B4A', glyph: 'triangle', + TORCH: { + legacyName: 'VOLUME SCORER', tag: 'TORCH', sport: 'nba', color: '#FF6B4A', glyph: 'triangle', description: 'High usage, shot-dependent scorer', propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] }, - education: 'Volume scorers carry a heavy shot diet, so their points props track usage closely. When they get their normal touches, points clear reliably; assists and threes swing with game script.', + education: 'Torches carry a heavy shot diet, so their points props track usage closely. When they get their normal touches, points clear reliably; assists and threes swing with game script.', }, - 'FLOOR GENERAL': { - tag: 'FLOOR GEN', sport: 'nba', color: '#4A9EFF', glyph: 'node', + CONDUCTOR: { + legacyName: 'FLOOR GENERAL', tag: 'CONDUCTOR', sport: 'nba', color: '#4A9EFF', glyph: 'node', description: 'Assist-heavy playmaker', propDNA: { reliable: ['assists'], volatile: ['points', 'threes'] }, - education: 'Floor generals create for others first. Assists are their most stable prop because the offense runs through them; their scoring fluctuates with shot selection and matchup.', + education: 'Conductors create for others first. Assists are their most stable prop because the offense runs through them; their scoring fluctuates with shot selection and matchup.', }, - 'TWO-WAY ANCHOR': { - tag: 'TW ANCHOR', sport: 'nba', color: '#A78BFA', glyph: 'shield', + FORTRESS: { + legacyName: 'TWO-WAY ANCHOR', tag: 'FORTRESS', sport: 'nba', color: '#A78BFA', glyph: 'shield', description: 'Defense, rebounds, blocks', propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] }, - education: 'Two-way anchors generate value on the defensive glass and at the rim. Rebounds and blocks are matchup-resilient; their scoring depends on how much offense flows their way.', + education: 'A Fortress generates value on the defensive glass and at the rim. Rebounds and blocks are matchup-resilient; their scoring depends on how much offense flows their way.', }, - 'STRETCH BIG': { - tag: 'STRETCH BIG', sport: 'nba', color: '#2DD4BF', glyph: 'target', + ARTILLERY: { + legacyName: 'STRETCH BIG', tag: 'ARTILLERY', sport: 'nba', color: '#2DD4BF', glyph: 'target', description: 'Floor-spacing shooting big', propDNA: { reliable: ['threes', 'rebounds'], volatile: ['assists', 'blocks'] }, - education: 'Stretch bigs space the floor and crash the glass. Threes and rebounds are their bread and butter; assists and blocks are situational.', + education: 'Artillery bigs space the floor and crash the glass. Threes and rebounds are their bread and butter; assists and blocks are situational.', }, - 'USAGE SPONGE': { - tag: 'USG SPONGE', sport: 'nba', color: '#FFB347', glyph: 'uparrow', + SURGE: { + legacyName: 'USAGE SPONGE', tag: 'SURGE', sport: 'nba', color: '#FFB347', glyph: 'uparrow', description: 'Usage spikes when stars sit', propDNA: { reliable: ['points'], volatile: ['assists', 'rebounds'] }, - education: 'Usage sponges soak up shots when a star sits or is injured. Their points props spike on cascade nights — read the injury report before trusting the baseline.', + education: 'A Surge soaks up shots when a star sits or is injured. Their points props spike on cascade nights — read the injury report before trusting the baseline.', }, - 'COMBO GUARD': { - tag: 'COMBO GD', sport: 'nba', color: '#00D4A0', glyph: 'twin', + 'DUAL THREAT': { + legacyName: 'COMBO GUARD', tag: 'DUAL THREAT', sport: 'nba', color: '#00D4A0', glyph: 'twin', description: 'Scoring + playmaking hybrid', propDNA: { reliable: ['points', 'assists'], volatile: ['rebounds'] }, - education: 'Combo guards score and create in equal measure, so points and assists both stay in play. Rebounds are the volatile leg for their size.', + education: 'Dual Threats score and create in equal measure, so points and assists both stay in play. Rebounds are the volatile leg for their size.', }, - 'ROLE GLUE': { - tag: 'ROLE GLUE', sport: 'nba', color: '#9499A8', glyph: 'chain', + CONNECTOR: { + legacyName: 'ROLE GLUE', tag: 'CONNECTOR', sport: 'nba', color: '#9499A8', glyph: 'chain', description: 'Low-usage specialist', propDNA: { reliable: [], volatile: ['points', 'assists', 'rebounds'] }, - education: 'Role glue players do the little things at low usage. Their counting props are thin and matchup-dependent — they reward unders more often than overs.', + education: 'Connectors do the little things at low usage. Their counting props are thin and matchup-dependent — they reward unders more often than overs.', }, - 'TRANSITION ENGINE': { - tag: 'TRANS ENG', sport: 'nba', color: '#22D3EE', glyph: 'chevrons', + FASTBREAK: { + legacyName: 'TRANSITION ENGINE', tag: 'FASTBREAK', sport: 'nba', color: '#22D3EE', glyph: 'chevrons', description: 'Pace-pushing fast-break threat', propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] }, - education: 'Transition engines feast in the open floor. Their points correlate with game pace — target overs in projected up-tempo matchups.', + education: 'Fastbreaks feast in the open floor. Their points correlate with game pace — target overs in projected up-tempo matchups.', }, - 'POST SCORER': { - tag: 'POST SCORER', sport: 'nba', color: '#FF5C5C', glyph: 'postup', + 'PAINT BOSS': { + legacyName: 'POST SCORER', tag: 'PAINT BOSS', sport: 'nba', color: '#FF5C5C', glyph: 'postup', description: 'Back-to-basket interior scorer', propDNA: { reliable: ['points', 'rebounds'], volatile: ['threes', 'assists'] }, - education: 'Post scorers operate in the paint. Points and rebounds are reliable against most fronts; perimeter props are noise.', + education: 'A Paint Boss operates in the paint. Points and rebounds are reliable against most fronts; perimeter props are noise.', }, - 'DEFENSIVE SPECIALIST': { - tag: 'DEF SPEC', sport: 'nba', color: '#6366F1', glyph: 'shieldCheck', + LOCKDOWN: { + legacyName: 'DEFENSIVE SPECIALIST', tag: 'LOCKDOWN', sport: 'nba', color: '#6366F1', glyph: 'shieldCheck', description: 'Perimeter stopper, low usage', propDNA: { reliable: ['steals'], volatile: ['points', 'assists'] }, - education: 'Defensive specialists earn minutes with their on-ball defense. Steals and blocks carry their card; offensive props are low-volume and streaky.', + education: 'Lockdowns earn minutes with their on-ball defense. Steals and blocks carry their card; offensive props are low-volume and streaky.', }, - 'POINT FORWARD': { - tag: 'PT FWD', sport: 'nba', color: '#38BDF8', glyph: 'half', + SWITCHBOARD: { + legacyName: 'POINT FORWARD', tag: 'SWITCHBOARD', sport: 'nba', color: '#38BDF8', glyph: 'half', description: 'Oversized primary creator', propDNA: { reliable: ['points', 'assists', 'rebounds'], volatile: ['threes'] }, - education: 'Point forwards run the offense from a wing or big body, so points, assists, and rebounds all stay live. Their three-point output is the swing factor.', + education: 'Switchboards run the offense from a wing or big body, so points, assists, and rebounds all stay live. Their three-point output is the swing factor.', }, - SLASHER: { - tag: 'SLASHER', sport: 'nba', color: '#FB923C', glyph: 'slash', - description: 'Rim-attacking, foul-drawing driver', + ARCHITECT: { + legacyName: 'SLASHER', tag: 'ARCHITECT', sport: 'nba', color: '#FB923C', glyph: 'slash', + description: 'Self-created shot maker, rim-attacking', propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] }, - education: 'Slashers live at the rim and the free-throw line. Points are stable; their three-point props are volatile because they rarely settle for jumpers.', + education: 'Architects manufacture their own shot at the rim and the line. Points are stable; their three-point props are volatile because they rarely settle for jumpers.', }, - 'RIM RUNNER': { - tag: 'RIM RUN', sport: 'nba', color: '#F472B6', glyph: 'arc', + PISTON: { + legacyName: 'RIM RUNNER', tag: 'PISTON', sport: 'nba', color: '#F472B6', glyph: 'arc', description: 'Lob and putback finisher', propDNA: { reliable: ['rebounds'], volatile: ['points', 'assists'] }, - education: 'Rim runners finish lobs and putbacks. Rebounds are reliable; their scoring depends entirely on feeds from creators.', + education: 'Pistons finish lobs and putbacks. Rebounds are reliable; their scoring depends entirely on feeds from creators.', }, - '3-AND-D': { - tag: '3&D', sport: 'nba', color: '#818CF8', glyph: 'crosshair', + SENTINEL: { + legacyName: '3-AND-D', tag: 'SENTINEL', sport: 'nba', color: '#818CF8', glyph: 'crosshair', description: 'Catch-and-shoot plus defense', propDNA: { reliable: ['threes'], volatile: ['points', 'assists'] }, - education: '3-and-D wings catch and shoot. Threes are their signature prop; total points swing with how many open looks the offense generates.', + education: 'Sentinels catch and shoot. Threes are their signature prop; total points swing with how many open looks the offense generates.', }, - 'SIXTH MAN': { - tag: '6TH MAN', sport: 'nba', color: '#FACC15', glyph: 'bolt', + IGNITER: { + legacyName: 'SIXTH MAN', tag: 'IGNITER', sport: 'nba', color: '#FACC15', glyph: 'bolt', description: 'Bench scoring spark', propDNA: { reliable: ['points'], volatile: ['rebounds', 'assists'] }, - education: 'Sixth men provide instant offense off the bench. Their points props depend on minutes — confirm the rotation before betting overs.', + education: 'Igniters provide instant offense off the bench. Their points props depend on minutes — confirm the rotation before betting overs.', }, // ───────── WNBA-unique (5) ───────── - 'POST FACILITATOR': { - tag: 'POST FAC', sport: 'wnba', color: '#C084FC', glyph: 'node', + DISTRIBUTOR: { + legacyName: 'POST FACILITATOR', tag: 'DISTRIBUTOR', sport: 'wnba', color: '#C084FC', glyph: 'node', description: 'Playmaking hub from the post', propDNA: { reliable: ['assists', 'rebounds'], volatile: ['threes'] }, - education: 'Post facilitators orchestrate from the elbow and block. Assists and rebounds are reliable; perimeter shooting is the volatile leg.', + education: 'Distributors orchestrate from the elbow and block. Assists and rebounds are reliable; perimeter shooting is the volatile leg.', }, - 'TWO-WAY WING': { - tag: 'TW WING', sport: 'wnba', color: '#A78BFA', glyph: 'shieldCheck', - description: 'Two-way perimeter wing', + SHIELD: { + legacyName: 'TWO-WAY WING', tag: 'SHIELD', sport: 'wnba', color: '#A78BFA', glyph: 'shieldCheck', + description: 'Two-way perimeter forward', propDNA: { reliable: ['points', 'steals'], volatile: ['assists'] }, - education: 'Two-way wings contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.', + education: 'Shields contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.', }, - 'STRETCH FORWARD': { - tag: 'STRETCH FWD', sport: 'wnba', color: '#2DD4BF', glyph: 'target', + RANGE: { + legacyName: 'STRETCH FORWARD', tag: 'RANGE', sport: 'wnba', color: '#2DD4BF', glyph: 'target', description: 'Floor-spacing forward', propDNA: { reliable: ['threes', 'points'], volatile: ['assists', 'blocks'] }, - education: 'Stretch forwards space the floor from the four. Threes and points are reliable; interior props are matchup-dependent.', + education: 'Range forwards space the floor from the four. Threes and points are reliable; interior props are matchup-dependent.', }, - 'SLASHING GUARD': { - tag: 'SLASH GD', sport: 'wnba', color: '#FB923C', glyph: 'slash', - description: 'Downhill driving guard', + SPARK: { + legacyName: 'SLASHING GUARD', tag: 'SPARK', sport: 'wnba', color: '#FB923C', glyph: 'slash', + description: 'Downhill scoring guard', propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] }, - education: 'Slashing guards attack downhill. Points are stable; three-point props are the volatile leg since they prioritize the rim.', + education: 'Sparks attack downhill. Points are stable; three-point props are the volatile leg since they prioritize the rim.', }, - 'INTERIOR ANCHOR': { - tag: 'INT ANCHOR', sport: 'wnba', color: '#6366F1', glyph: 'shield', - description: 'Paint defender and rebounder', + ANCHOR: { + legacyName: 'INTERIOR ANCHOR', tag: 'ANCHOR', sport: 'wnba', color: '#6366F1', glyph: 'shield', + description: 'Dominant paint defender and rebounder', propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] }, - education: 'Interior anchors own the paint. Rebounds and blocks are reliable; their scoring depends on post touches.', + education: 'Anchors own the paint. Rebounds and blocks are reliable; their scoring depends on post touches.', }, // ───────── MLB (15) ───────── - 'POWER PULL': { - tag: 'POWER PULL', sport: 'mlb', color: '#FF5C5C', glyph: 'batball', - description: 'HR-dependent, high strikeout power', + BOMBER: { + legacyName: 'POWER SLUGGER', tag: 'BOMBER', sport: 'mlb', color: '#FF6B4A', glyph: 'triangle', + description: 'Middle-of-the-order power producer', propDNA: { reliable: ['total_bases', 'home_runs'], volatile: ['hits'] }, - education: 'Power-pull hitters live and die by the long ball. Total bases and home-run props carry their value; their batting-average-driven props (hits) are volatile from the strikeout risk.', + education: 'Bombers drive the ball over the fence. Total bases and home-run props carry their value; their batting-average-driven props (hits) are the volatile leg from strikeout risk.', }, - CONTACT: { - tag: 'CONTACT', sport: 'mlb', color: '#3DDC84', glyph: 'crosshair', + BRUSH: { + legacyName: 'CONTACT', tag: 'BRUSH', sport: 'mlb', color: '#3DDC84', glyph: 'crosshair', description: 'High average, low strikeout', propDNA: { reliable: ['hits'], volatile: ['home_runs', 'total_bases'] }, - education: 'Contact hitters rarely strike out, so their hits props are among the most reliable in baseball. Power props (HR, TB) are the volatile leg.', + education: 'Brush hitters rarely strike out, so their hits props are among the most reliable in baseball. Power props (HR, TB) are the volatile leg.', }, - 'RUN PRODUCER': { - tag: 'RUN PROD', sport: 'mlb', color: '#4A9EFF', glyph: 'diamond', + DRIVER: { + legacyName: 'RUN PRODUCER', tag: 'DRIVER', sport: 'mlb', color: '#4A9EFF', glyph: 'diamond', description: 'RBI-dependent, lineup context', propDNA: { reliable: ['rbi'], volatile: ['hits', 'home_runs'] }, - education: 'Run producers hit in the heart of the order. RBI props track lineup context — strong with runners on base; their individual hit props are more variable.', + education: 'Drivers hit in the heart of the order. RBI props track lineup context — strong with runners on base; their individual hit props are more variable.', }, - ACE: { - tag: 'ACE', sport: 'mlb', color: '#A78BFA', glyph: 'star', + ALPHA: { + legacyName: 'ACE', tag: 'ALPHA', sport: 'mlb', color: '#A78BFA', glyph: 'star', description: 'High K/9, low WHIP, deep games', propDNA: { reliable: ['strikeouts', 'innings_pitched'], volatile: ['earned_runs'] }, - education: 'Aces miss bats and go deep. Strikeout and innings props are their most reliable; earned-run props are noisier because one swing can change a line.', + education: 'Alphas miss bats and go deep. Strikeout and innings props are their most reliable; earned-run props are noisier because one swing can change a line.', }, - 'BULLPEN ARM': { - tag: 'BULLPEN', sport: 'mlb', color: '#FFB347', glyph: 'bolt', - description: 'Short outings, high leverage', + WHIFF: { + legacyName: 'STRIKEOUT ARTIST', tag: 'WHIFF', sport: 'mlb', color: '#FFB347', glyph: 'bolt', + description: 'Bat-missing arm, high K with traffic', propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] }, - education: 'Bullpen arms throw short, high-leverage outings. Strikeout props can hit in one inning; innings and earned-run props are too small a sample to trust.', + education: 'Whiffs rack up strikeouts but allow more baserunners than an Alpha. The strikeout prop is reliable; earned-run and innings props swing with their traffic.', }, - 'SPEED THREAT': { - tag: 'SPEED', sport: 'mlb', color: '#2DD4BF', glyph: 'chevrons', + GHOST: { + legacyName: 'SPEED THREAT', tag: 'GHOST', sport: 'mlb', color: '#2DD4BF', glyph: 'chevrons', description: 'Stolen bases, speed score', propDNA: { reliable: ['stolen_bases', 'runs'], volatile: ['home_runs'] }, - education: 'Speed threats turn singles into runs. Stolen-base and runs props are their lane; power props rarely clear.', + education: 'Ghosts turn singles into runs. Stolen-base and runs props are their lane; power props rarely clear.', }, - 'TWO-WAY PLAYER': { - tag: 'TWO-WAY', sport: 'mlb', color: '#F472B6', glyph: 'half', + HYBRID: { + legacyName: 'TWO-WAY PLAYER', tag: 'HYBRID', sport: 'mlb', color: '#F472B6', glyph: 'half', description: 'Bats and pitches at elite level', propDNA: { reliable: ['total_bases', 'strikeouts'], volatile: ['hits'] }, - education: 'Two-way players produce on both sides of the ball. Read which role they fill that day — their batting and pitching props live on different lines.', + education: 'Hybrids produce on both sides of the ball. Read which role they fill that day — their batting and pitching props live on different lines.', }, - 'UTILITY PLAYER': { - tag: 'UTILITY', sport: 'mlb', color: '#22D3EE', glyph: 'plus', + FLEX: { + legacyName: 'UTILITY PLAYER', tag: 'FLEX', sport: 'mlb', color: '#22D3EE', glyph: 'plus', description: 'Multi-position lineup flex', propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] }, - education: 'Utility players move around the lineup and the diamond. Their props are matchup- and slot-dependent — confirm they are starting before betting.', + education: 'Flex players move around the lineup and the diamond. Their props are matchup- and slot-dependent — confirm they are starting before betting.', }, - 'INNINGS EATER': { - tag: 'INN EATER', sport: 'mlb', color: '#818CF8', glyph: 'clock', - description: 'Durable, deep-start workhorse', + WORKHORSE: { + legacyName: 'INNINGS EATER', tag: 'WORKHORSE', sport: 'mlb', color: '#818CF8', glyph: 'clock', + description: 'Durable, deep-start arm', propDNA: { reliable: ['innings_pitched'], volatile: ['strikeouts', 'earned_runs'] }, - education: 'Innings eaters pitch deep without elite stuff. Innings props are reliable; strikeout and earned-run props are more variable since they pitch to contact.', + education: 'Workhorses pitch deep without elite stuff. Innings props are reliable; strikeout and earned-run props are more variable since they pitch to contact.', }, - 'POWER SLUGGER': { - tag: 'SLUGGER', sport: 'mlb', color: '#FF6B4A', glyph: 'triangle', - description: 'All-fields power producer', - propDNA: { reliable: ['total_bases', 'rbi'], volatile: ['stolen_bases'] }, - education: 'Power sluggers drive the ball to all fields. Total bases and RBI are reliable; speed props are not part of their game.', - }, - 'TABLE SETTER': { - tag: 'TABLE SET', sport: 'mlb', color: '#38BDF8', glyph: 'diamondLine', + CATALYST: { + legacyName: 'TABLE SETTER', tag: 'CATALYST', sport: 'mlb', color: '#38BDF8', glyph: 'diamondLine', description: 'On-base leadoff catalyst', propDNA: { reliable: ['hits', 'runs'], volatile: ['rbi', 'home_runs'] }, - education: 'Table setters get on base and score. Hits and runs props are their lane; RBI and power props sit lower in their profile.', + education: 'Catalysts get on base and score. Hits and runs props are their lane; RBI and power props sit lower in their profile.', }, - 'GAP HITTER': { - tag: 'GAP', sport: 'mlb', color: '#34D399', glyph: 'uparrow', - description: 'Doubles and extra-base gaps', + MIRROR: { + legacyName: 'GAP HITTER', tag: 'MIRROR', sport: 'mlb', color: '#34D399', glyph: 'uparrow', + description: 'Gap-to-gap line-drive bat', propDNA: { reliable: ['total_bases', 'hits'], volatile: ['home_runs'] }, - education: 'Gap hitters spray doubles. Total bases and hits are reliable; home-run props are the volatile leg of their extra-base profile.', + education: 'Mirror hitters spray doubles to both gaps. Total bases and hits are reliable; home-run props are the volatile leg of their extra-base profile.', }, - CLOSER: { - tag: 'CLOSER', sport: 'mlb', color: '#FB7185', glyph: 'lock', + HAMMER: { + legacyName: 'CLOSER', tag: 'HAMMER', sport: 'mlb', color: '#FB7185', glyph: 'lock', description: 'Ninth-inning save specialist', propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] }, - education: 'Closers throw one high-leverage inning. Strikeout props can hit in a clean save; everything else is a one-inning coin flip.', + education: 'Hammers throw one high-leverage inning. Strikeout props can hit in a clean save; everything else is a one-inning coin flip.', }, - SWINGMAN: { - tag: 'SWINGMAN', sport: 'mlb', color: '#FBBF24', glyph: 'swap', - description: 'Spot starter and long relief', + SINKER: { + legacyName: 'SWINGMAN', tag: 'SINKER', sport: 'mlb', color: '#FBBF24', glyph: 'swap', + description: 'Groundball spot-starter / long relief', propDNA: { reliable: [], volatile: ['strikeouts', 'innings_pitched', 'earned_runs'] }, - education: 'Swingmen bounce between starting and relief. Their workload is unpredictable, so all of their props carry role risk — confirm the assignment.', + education: 'Sinkers bounce between starting and relief and pitch to contact. Their workload is unpredictable, so all of their props carry role risk — confirm the assignment.', }, - 'DEFENSIVE WIZARD': { - tag: 'DEF WIZ', sport: 'mlb', color: '#6366F1', glyph: 'shieldCheck', - description: 'Glove-first defensive value', + BRIDGE: { + legacyName: 'BULLPEN ARM', tag: 'BRIDGE', sport: 'mlb', color: '#6366F1', glyph: 'shieldCheck', + description: 'Setup / high-leverage middle relief', + propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] }, + education: 'Bridges throw short, high-leverage outings. Strikeout props can hit in one inning; innings and earned-run props are too small a sample to trust.', + }, + SWITCH: { + legacyName: 'DEFENSIVE WIZARD', tag: 'SWITCH', sport: 'mlb', color: '#FF5C5C', glyph: 'batball', + description: 'Platoon-leveraged, glove-first bat', propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] }, - education: 'Defensive wizards earn their spot with the glove. Their offensive props are thin and bottom-of-the-order dependent — lean unders.', + education: 'Switch bats earn their spot with the glove and favorable platoon splits. Their offensive props are thin and matchup-dependent — lean unders outside their platoon edge.', }, - // ───────── Soccer (6) — present in the design map ───────── - POACHER: { - tag: 'POACHER', sport: 'soccer', color: '#FF5C5C', glyph: 'crosshair', + // ───────── Soccer (6) ───────── + FINISHER: { + legacyName: 'POACHER', tag: 'FINISHER', sport: 'soccer', color: '#FF5C5C', glyph: 'crosshair', description: 'Penalty-box finisher', propDNA: { reliable: ['shots_on_target', 'goals'], volatile: ['assists'] }, - education: 'Poachers finish inside the box. Shots-on-target and goals are their props; they rarely create for others.', + education: 'Finishers score inside the box. Shots-on-target and goals are their props; they rarely create for others.', }, - CREATOR: { - tag: 'CREATOR', sport: 'soccer', color: '#4A9EFF', glyph: 'node', + MAESTRO: { + legacyName: 'CREATOR', tag: 'MAESTRO', sport: 'soccer', color: '#4A9EFF', glyph: 'node', description: 'Chance-creating playmaker', propDNA: { reliable: ['assists', 'passes'], volatile: ['goals'] }, - education: 'Creators set the table. Assists and passing props are reliable; their goal output is the volatile leg.', + education: 'Maestros set the table. Assists and passing props are reliable; their goal output is the volatile leg.', }, - 'TARGET MAN': { - tag: 'TARGET', sport: 'soccer', color: '#FF6B4A', glyph: 'triangle', + TOWER: { + legacyName: 'TARGET MAN', tag: 'TOWER', sport: 'soccer', color: '#FF6B4A', glyph: 'triangle', description: 'Hold-up aerial striker', propDNA: { reliable: ['shots', 'shots_on_target'], volatile: ['goals', 'assists'] }, - education: 'Target men win aerial duels and hold the ball up. Shot props are reliable; conversion to goals is variable.', + education: 'Towers win aerial duels and hold the ball up. Shot props are reliable; conversion to goals is variable.', }, - 'BOX-TO-BOX': { - tag: 'B2B', sport: 'soccer', color: '#00D4A0', glyph: 'chevrons', + MOTOR: { + legacyName: 'BOX-TO-BOX', tag: 'MOTOR', sport: 'soccer', color: '#00D4A0', glyph: 'chevrons', description: 'All-action central midfielder', propDNA: { reliable: ['tackles', 'passes'], volatile: ['goals', 'shots'] }, - education: 'Box-to-box midfielders cover every blade of grass. Tackles and passing props are reliable; their attacking output swings by role.', + education: 'Motors cover every blade of grass. Tackles and passing props are reliable; their attacking output swings by role.', }, - 'WING WIZARD': { - tag: 'WING', sport: 'soccer', color: '#2DD4BF', glyph: 'slash', + BLADE: { + legacyName: 'WING WIZARD', tag: 'BLADE', sport: 'soccer', color: '#2DD4BF', glyph: 'slash', description: 'Dribbling wide threat', propDNA: { reliable: ['shots', 'assists'], volatile: ['goals'] }, - education: 'Wing wizards beat defenders wide. Shots and assists are their lane; goals come in streaks.', + education: 'Blades beat defenders wide. Shots and assists are their lane; goals come in streaks.', }, - 'SWEEPER KEEPER': { - tag: 'SWEEPER', sport: 'soccer', color: '#A78BFA', glyph: 'shield', + WALL: { + legacyName: 'SWEEPER KEEPER', tag: 'WALL', sport: 'soccer', color: '#A78BFA', glyph: 'shield', description: 'Distributing goalkeeper', propDNA: { reliable: ['saves', 'passes'], volatile: ['goals_conceded'] }, - education: 'Sweeper keepers distribute and defend space. Saves and passing props are reliable; goals-conceded depends on the team in front of them.', + education: 'Walls distribute and defend space. Saves and passing props are reliable; goals-conceded depends on the team in front of them.', }, }; const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0); -/** - * NBA scorers — each returns a 0..1-ish weight from season averages. - * Inputs (all optional): ppg, rpg, apg, bpg, spg, threes (3PM/g), usg (%), - * fg3a (3PA/g), bench (bool), pos ('G'|'F'|'C'). - */ +/** NBA scorers — VYNDR Original keys. */ function scoreNBA(s) { const ppg = num(s.ppg), rpg = num(s.rpg), apg = num(s.apg), bpg = num(s.bpg), spg = num(s.spg), threes = num(s.threes), usg = num(s.usg), fg3a = num(s.fg3a); const pos = (s.pos || '').toUpperCase(); const isBig = pos === 'C' || pos === 'F-C' || pos === 'C-F'; return { - 'VOLUME SCORER': ppg >= 22 ? (ppg - 16) / 14 + (usg >= 28 ? 0.3 : 0) : 0, - 'FLOOR GENERAL': apg >= 6 ? (apg - 3) / 7 : apg >= 4 ? 0.2 : 0, - 'TWO-WAY ANCHOR': (bpg >= 1.3 ? bpg / 3 : 0) + (rpg >= 8 ? (rpg - 6) / 8 : 0), - 'STRETCH BIG': isBig && threes >= 1.2 ? 0.5 + threes / 6 : 0, - 'USAGE SPONGE': s.bench && usg >= 24 ? 0.5 : 0, - 'COMBO GUARD': ppg >= 15 && apg >= 3 && apg < 7 ? 0.4 + apg / 20 : 0, - 'ROLE GLUE': usg > 0 && usg < 16 && ppg < 10 ? 0.5 : 0, - 'TRANSITION ENGINE': ppg >= 16 && spg >= 1.2 ? 0.3 : 0, - 'POST SCORER': isBig && ppg >= 16 && threes < 1 ? 0.5 + ppg / 50 : 0, - 'DEFENSIVE SPECIALIST': spg >= 1.4 && usg < 18 ? 0.5 + spg / 6 : 0, - 'POINT FORWARD': apg >= 5 && (pos === 'F' || pos === 'G-F' || pos === 'F-G') && ppg >= 16 ? 0.6 + apg / 16 : 0, - SLASHER: ppg >= 16 && fg3a < 4 && !isBig ? 0.35 : 0, - 'RIM RUNNER': isBig && rpg >= 7 && ppg < 16 && threes < 0.5 ? 0.45 : 0, - '3-AND-D': threes >= 1.6 && usg < 20 && spg >= 0.9 ? 0.5 + threes / 8 : 0, - 'SIXTH MAN': s.bench && ppg >= 12 ? 0.4 + ppg / 40 : 0, + TORCH: ppg >= 22 ? (ppg - 16) / 14 + (usg >= 28 ? 0.3 : 0) : 0, + CONDUCTOR: apg >= 6 ? (apg - 3) / 7 : apg >= 4 ? 0.2 : 0, + FORTRESS: (bpg >= 1.3 ? bpg / 3 : 0) + (rpg >= 8 ? (rpg - 6) / 8 : 0), + ARTILLERY: isBig && threes >= 1.2 ? 0.5 + threes / 6 : 0, + SURGE: s.bench && usg >= 24 ? 0.5 : 0, + 'DUAL THREAT': ppg >= 15 && apg >= 3 && apg < 7 ? 0.4 + apg / 20 : 0, + CONNECTOR: usg > 0 && usg < 16 && ppg < 10 ? 0.5 : 0, + FASTBREAK: ppg >= 16 && spg >= 1.2 ? 0.3 : 0, + 'PAINT BOSS': isBig && ppg >= 16 && threes < 1 ? 0.5 + ppg / 50 : 0, + LOCKDOWN: spg >= 1.4 && usg < 18 ? 0.5 + spg / 6 : 0, + SWITCHBOARD: apg >= 5 && (pos === 'F' || pos === 'G-F' || pos === 'F-G') && ppg >= 16 ? 0.6 + apg / 16 : 0, + ARCHITECT: ppg >= 16 && fg3a < 4 && !isBig ? 0.35 : 0, + PISTON: isBig && rpg >= 7 && ppg < 16 && threes < 0.5 ? 0.45 : 0, + SENTINEL: threes >= 1.6 && usg < 20 && spg >= 0.9 ? 0.5 + threes / 8 : 0, + IGNITER: s.bench && ppg >= 12 ? 0.4 + ppg / 40 : 0, }; } function scoreWNBA(s) { - // WNBA reuses NBA archetypes plus 5 unique ones. Start from the NBA scores - // (filtered to WNBA-applicable) and add the unique forwards/guards. const ppg = num(s.ppg), rpg = num(s.rpg), apg = num(s.apg), bpg = num(s.bpg), spg = num(s.spg), threes = num(s.threes), usg = num(s.usg); const pos = (s.pos || '').toUpperCase(); const isBig = pos === 'C' || pos === 'F' || pos === 'F-C'; return { - 'VOLUME SCORER': ppg >= 18 ? (ppg - 12) / 12 + (usg >= 26 ? 0.25 : 0) : 0, - 'FLOOR GENERAL': apg >= 5 ? (apg - 2) / 6 : 0, - 'COMBO GUARD': ppg >= 14 && apg >= 3 && apg < 6 ? 0.4 + apg / 18 : 0, - 'POST FACILITATOR': isBig && apg >= 3 && rpg >= 7 ? 0.6 + apg / 12 : 0, - 'TWO-WAY WING': !isBig && ppg >= 12 && spg >= 1.2 ? 0.5 + spg / 6 : 0, - 'STRETCH FORWARD': (pos === 'F' || isBig) && threes >= 1.2 ? 0.5 + threes / 5 : 0, - 'SLASHING GUARD': pos.startsWith('G') && ppg >= 14 && threes < 1.5 ? 0.45 : 0, - 'INTERIOR ANCHOR': isBig && (bpg >= 1 || rpg >= 8) ? 0.5 + rpg / 16 : 0, - 'DEFENSIVE SPECIALIST': spg >= 1.5 && usg < 18 ? 0.45 : 0, + TORCH: ppg >= 18 ? (ppg - 12) / 12 + (usg >= 26 ? 0.25 : 0) : 0, + CONDUCTOR: apg >= 5 ? (apg - 2) / 6 : 0, + 'DUAL THREAT': ppg >= 14 && apg >= 3 && apg < 6 ? 0.4 + apg / 18 : 0, + DISTRIBUTOR: isBig && apg >= 3 && rpg >= 7 ? 0.6 + apg / 12 : 0, + SHIELD: !isBig && ppg >= 12 && spg >= 1.2 ? 0.5 + spg / 6 : 0, + RANGE: (pos === 'F' || isBig) && threes >= 1.2 ? 0.5 + threes / 5 : 0, + SPARK: pos.startsWith('G') && ppg >= 14 && threes < 1.5 ? 0.45 : 0, + ANCHOR: isBig && (bpg >= 1 || rpg >= 8) ? 0.5 + rpg / 16 : 0, + LOCKDOWN: spg >= 1.5 && usg < 18 ? 0.45 : 0, }; } /** - * MLB scorers. Hitters and pitchers are disjoint; `isPitcher` (or presence of - * era/k9) routes to the pitcher archetypes. + * MLB scorers. Hitters and pitchers are disjoint. * Hitter inputs: avg, hr, rbi, sb, ops, runs, k_rate, doubles. * Pitcher inputs: era, k9, whip, ip_per_start, saves, role ('SP'|'RP'|'CL'). */ @@ -340,46 +330,47 @@ function scoreMLB(s) { const era = num(s.era), k9 = num(s.k9), whip = num(s.whip), ip = num(s.ip_per_start), saves = num(s.saves); return { - ACE: k9 >= 9.5 && era <= 3.6 && ip >= 5.5 ? 0.6 + k9 / 30 : k9 >= 9 ? 0.3 : 0, - 'INNINGS EATER': ip >= 6 && k9 < 9 ? 0.55 + ip / 20 : 0, - CLOSER: role === 'CL' || saves >= 10 ? 0.7 + saves / 60 : 0, - 'BULLPEN ARM': role === 'RP' && saves < 10 ? 0.55 : ip > 0 && ip < 3 ? 0.4 : 0, - SWINGMAN: role === 'SP' && ip < 5 && ip > 0 ? 0.4 : 0, - 'TWO-WAY PLAYER': s.twoWay ? 0.8 : 0, + ALPHA: k9 >= 9.5 && era <= 3.6 && ip >= 5.5 ? 0.6 + k9 / 30 : k9 >= 9 && era <= 3.6 ? 0.3 : 0, + WHIFF: k9 >= 10.5 && era > 3.6 ? 0.6 + k9 / 30 : k9 >= 9.5 && era > 3.8 ? 0.35 : 0, + WORKHORSE: ip >= 6 && k9 < 9 ? 0.55 + ip / 20 : 0, + HAMMER: role === 'CL' || saves >= 10 ? 0.7 + saves / 60 : 0, + BRIDGE: role === 'RP' && saves < 10 ? 0.55 : ip > 0 && ip < 3 ? 0.4 : 0, + SINKER: role === 'SP' && ip < 5 && ip > 0 ? 0.4 : 0, + HYBRID: s.twoWay ? 0.8 : 0, }; } const avg = num(s.avg), hr = num(s.hr), rbi = num(s.rbi), sb = num(s.sb), ops = num(s.ops), runs = num(s.runs), kRate = num(s.k_rate), doubles = num(s.doubles); return { - 'POWER PULL': hr >= 20 && kRate >= 24 ? 0.6 + hr / 60 : hr >= 18 ? 0.3 : 0, - 'POWER SLUGGER': hr >= 20 && ops >= 0.85 && kRate < 24 ? 0.6 + hr / 60 : 0, - CONTACT: avg >= 0.28 && kRate < 16 ? 0.6 + (avg - 0.25) * 2 : avg >= 0.29 ? 0.4 : 0, - 'RUN PRODUCER': rbi >= 50 && hr >= 12 ? 0.5 + rbi / 200 : 0, - 'SPEED THREAT': sb >= 15 ? 0.6 + sb / 60 : sb >= 10 ? 0.35 : 0, - 'TABLE SETTER': runs >= 50 && sb >= 8 && hr < 15 ? 0.5 + runs / 200 : 0, - 'GAP HITTER': doubles >= 25 && hr < 20 ? 0.5 + doubles / 80 : 0, - 'UTILITY PLAYER': s.utility ? 0.5 : 0, - 'DEFENSIVE WIZARD': s.glove && avg < 0.25 ? 0.5 : 0, - 'TWO-WAY PLAYER': s.twoWay ? 0.8 : 0, + // BOMBER is the single power archetype — fires for any high-HR bat (incl. + // high-strikeout sluggers like Judge), so power hitters classify as BOMBER. + BOMBER: hr >= 20 ? 0.6 + hr / 60 : hr >= 15 ? 0.3 : 0, + BRUSH: avg >= 0.28 && kRate < 16 ? 0.6 + (avg - 0.25) * 2 : avg >= 0.29 ? 0.4 : 0, + DRIVER: rbi >= 50 && hr >= 12 ? 0.5 + rbi / 200 : 0, + GHOST: sb >= 15 ? 0.6 + sb / 60 : sb >= 10 ? 0.35 : 0, + CATALYST: runs >= 50 && sb >= 8 && hr < 15 ? 0.5 + runs / 200 : 0, + MIRROR: doubles >= 25 && hr < 20 ? 0.5 + doubles / 80 : 0, + FLEX: s.utility ? 0.5 : 0, + SWITCH: s.glove && avg < 0.25 ? 0.5 : 0, + HYBRID: s.twoWay ? 0.8 : 0, }; } const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB }; -/** Look up an archetype's static descriptor by name (case-insensitive). */ +/** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */ function getArchetype(name) { if (!name) return null; const key = String(name).toUpperCase(); - const a = ARCHETYPES[key]; - if (!a) return null; - return { name: key, ...a }; + if (ARCHETYPES[key]) return { name: key, ...ARCHETYPES[key] }; + // Legacy-name fallback so old references still resolve. + const byLegacy = Object.entries(ARCHETYPES).find(([, a]) => a.legacyName === key); + return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null; } /** * Classify a player. Returns: * { sport, primary, secondary|null, blend: [{archetype, weight}] } - * primary/secondary are full descriptors (name, tag, color, glyph, description, - * propDNA, education). blend weights are normalized 0..1 over the top entries. */ function classify(sport, stats = {}) { const sp = String(sport || 'nba').toLowerCase(); @@ -392,8 +383,7 @@ function classify(sport, stats = {}) { .sort((a, b) => b[1] - a[1]); if (ranked.length === 0) { - // Fallback so the UI always has something to render. - const fallback = sp === 'mlb' ? 'UTILITY PLAYER' : sp === 'wnba' ? 'TWO-WAY WING' : 'ROLE GLUE'; + const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR'; return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] }; } @@ -402,7 +392,6 @@ function classify(sport, stats = {}) { const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) })); const primary = getArchetype(ranked[0][0]); - // Secondary only if it's a meaningful share (>= 40% of primary's score). const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4 ? getArchetype(ranked[1][0]) : null; diff --git a/src/services/depthChartService.js b/src/services/depthChartService.js index d060ae0..e652306 100644 --- a/src/services/depthChartService.js +++ b/src/services/depthChartService.js @@ -101,11 +101,13 @@ async function getCascadeProjection(sport, player, team, opts = {}) { // Distribute a fixed usage pool across teammates, weighting usage sponges and // high-usage creators heavier (mirrors the design's cascade framing). const POOL = Number.isFinite(opts.usagePool) ? opts.usagePool : 12; // % + // VYNDR Originals (Session 44): SURGE (usage sponge) benefits most; primary + // creators (TORCH/DUAL THREAT/SWITCHBOARD) next; low-usage glue least. const weightFor = (a) => { const k = norm(a); - if (k.includes('USAGE SPONGE')) return 3; - if (k.includes('VOLUME') || k.includes('COMBO') || k.includes('POINT FORWARD')) return 2; - if (k.includes('ROLE GLUE') || k.includes('SPECIALIST')) return 0.5; + if (k === 'SURGE' || k.includes('USAGE SPONGE')) return 3; + if (['TORCH', 'DUAL THREAT', 'SWITCHBOARD', 'IGNITER'].includes(k) || k.includes('VOLUME') || k.includes('COMBO') || k.includes('POINT FORWARD')) return 2; + if (k === 'CONNECTOR' || k === 'LOCKDOWN' || k.includes('ROLE GLUE') || k.includes('SPECIALIST')) return 0.5; return 1; }; const weights = teammates.map((m) => weightFor(m.archetype)); diff --git a/tests/unit/archetypeBadge.test.js b/tests/unit/archetypeBadge.test.js index 367c3fe..a7e97f2 100644 --- a/tests/unit/archetypeBadge.test.js +++ b/tests/unit/archetypeBadge.test.js @@ -10,21 +10,21 @@ const svc = require('../../src/services/archetypeService'); describe('archetypes lib — badge styling', () => { it('full variant fills with the archetype color + white text', () => { - const s = arch.badgeStyle('POWER PULL', 'full', 'md'); - expect(s.bg).toBe('#FF5C5C'); + const s = arch.badgeStyle('BOMBER', 'full', 'md'); + expect(s.bg).toBe('#FF6B4A'); expect(s.textColor).toBe('#FFFFFF'); - expect(s.borderColor).toBe('#FF5C5C'); + expect(s.borderColor).toBe('#FF6B4A'); }); it('ghost variant is transparent with a colored border', () => { - const s = arch.badgeStyle('FLOOR GENERAL', 'ghost', 'sm'); + const s = arch.badgeStyle('CONDUCTOR', 'ghost', 'sm'); expect(s.bg).toBe('transparent'); expect(s.textColor).toBe('#4A9EFF'); expect(s.borderColor).toBe('#4A9EFFCC'); }); it('tint variant (default) uses a low-alpha tinted background', () => { - const s = arch.badgeStyle('ACE'); + const s = arch.badgeStyle('ALPHA'); expect(s.bg).toBe('#A78BFA1F'); expect(s.borderColor).toBe('#A78BFA52'); }); diff --git a/tests/unit/archetypeService.test.js b/tests/unit/archetypeService.test.js index d5e1809..0b081b6 100644 --- a/tests/unit/archetypeService.test.js +++ b/tests/unit/archetypeService.test.js @@ -10,7 +10,7 @@ describe('archetypeService — registry', () => { expect(bySport).toEqual({ nba: 15, wnba: 5, mlb: 15, soccer: 6 }); }); - it('every archetype has name/tag/color/glyph/description/propDNA/education', () => { + it('every archetype has name/tag/color/glyph/description/propDNA/education/legacyName', () => { for (const [name, a] of Object.entries(svc.ARCHETYPES)) { expect(typeof a.tag).toBe('string'); expect(a.color).toMatch(/^#[0-9A-Fa-f]{6}$/); @@ -21,9 +21,20 @@ describe('archetypeService — registry', () => { expect(Array.isArray(a.propDNA.volatile)).toBe(true); expect(a.education.length).toBeGreaterThan(20); expect(name).toBe(name.toUpperCase()); + expect(typeof a.legacyName).toBe('string'); // VYNDR Originals keep the old label } }); + it('uses VYNDR Original names, not the old descriptive labels', () => { + const keys = Object.keys(svc.ARCHETYPES); + expect(keys).toContain('TORCH'); + expect(keys).toContain('BOMBER'); + expect(keys).toContain('ALPHA'); + expect(keys).not.toContain('VOLUME SCORER'); + expect(keys).not.toContain('POWER SLUGGER'); + expect(keys).not.toContain('ACE'); + }); + it('colors are unique WITHIN each sport (reused across sports by design)', () => { const bySport = {}; for (const a of Object.values(svc.ARCHETYPES)) { @@ -35,9 +46,12 @@ describe('archetypeService — registry', () => { } }); - it('getArchetype is case-insensitive and returns null for unknown', () => { - expect(svc.getArchetype('ace').name).toBe('ACE'); - expect(svc.getArchetype('Two-Way Anchor').name).toBe('TWO-WAY ANCHOR'); + it('getArchetype is case-insensitive, resolves legacy names, returns null for unknown', () => { + expect(svc.getArchetype('alpha').name).toBe('ALPHA'); + expect(svc.getArchetype('Fortress').name).toBe('FORTRESS'); + // legacy names still resolve to their VYNDR Original + expect(svc.getArchetype('ACE').name).toBe('ALPHA'); + expect(svc.getArchetype('POWER SLUGGER').name).toBe('BOMBER'); expect(svc.getArchetype('not a real one')).toBeNull(); }); }); @@ -45,7 +59,7 @@ describe('archetypeService — registry', () => { describe('archetypeService — NBA classification', () => { it('classifies a volume scorer', () => { const r = svc.classifyNBA({ ppg: 30, rpg: 5, apg: 4, usg: 33, threes: 2.5, pos: 'G' }); - expect(r.primary.name).toBe('VOLUME SCORER'); + expect(r.primary.name).toBe('TORCH'); expect(r.sport).toBe('nba'); }); @@ -58,7 +72,7 @@ describe('archetypeService — NBA classification', () => { it('classifies a two-way anchor (Wembanyama-type)', () => { const r = svc.classifyNBA({ ppg: 24, rpg: 11, apg: 3, bpg: 3.2, usg: 31, threes: 1.5, pos: 'C' }); - expect(['TWO-WAY ANCHOR', 'POST SCORER', 'STRETCH BIG']).toContain(r.primary.name); + expect(['FORTRESS', 'PAINT BOSS', 'ARTILLERY']).toContain(r.primary.name); }); it('produces a normalized blend that sums to ~1', () => { @@ -73,32 +87,32 @@ describe('archetypeService — MLB classification', () => { it('differentiates an Ace from an Innings Eater', () => { const ace = svc.classifyMLB({ role: 'SP', era: 2.9, k9: 11.5, whip: 0.98, ip_per_start: 6.2 }); const eater = svc.classifyMLB({ role: 'SP', era: 4.1, k9: 7.0, whip: 1.3, ip_per_start: 6.5 }); - expect(ace.primary.name).toBe('ACE'); - expect(eater.primary.name).toBe('INNINGS EATER'); + expect(ace.primary.name).toBe('ALPHA'); + expect(eater.primary.name).toBe('WORKHORSE'); }); it('classifies a closer', () => { const r = svc.classifyMLB({ role: 'CL', era: 2.2, k9: 12, saves: 24, ip_per_start: 1 }); - expect(r.primary.name).toBe('CLOSER'); + expect(r.primary.name).toBe('HAMMER'); }); - it('classifies a contact hitter and a power-pull hitter differently', () => { + it('classifies a contact hitter (BRUSH) and a power hitter (BOMBER) differently', () => { const contact = svc.classifyMLB({ avg: 0.315, hr: 8, rbi: 40, k_rate: 12, ops: 0.82 }); const power = svc.classifyMLB({ avg: 0.235, hr: 34, rbi: 88, k_rate: 30, ops: 0.86 }); - expect(contact.primary.name).toBe('CONTACT'); - expect(power.primary.name).toBe('POWER PULL'); + expect(contact.primary.name).toBe('BRUSH'); + expect(power.primary.name).toBe('BOMBER'); }); }); describe('archetypeService — WNBA classification', () => { it('classifies a dominant post / interior anchor (Wilson-type)', () => { const r = svc.classifyWNBA({ ppg: 27, rpg: 12, apg: 2, bpg: 2.3, usg: 31, pos: 'F' }); - expect(['INTERIOR ANCHOR', 'VOLUME SCORER', 'STRETCH FORWARD']).toContain(r.primary.name); + expect(['ANCHOR', 'TORCH', 'RANGE']).toContain(r.primary.name); }); it('classifies a floor general', () => { const r = svc.classifyWNBA({ ppg: 16, rpg: 4, apg: 7, usg: 24, pos: 'G' }); - expect(r.primary.name).toBe('FLOOR GENERAL'); + expect(r.primary.name).toBe('CONDUCTOR'); }); }); diff --git a/tests/unit/depthChartService.test.js b/tests/unit/depthChartService.test.js index df9c572..c0c2bb6 100644 --- a/tests/unit/depthChartService.test.js +++ b/tests/unit/depthChartService.test.js @@ -53,9 +53,9 @@ describe('getCascadeProjection', () => { it('distributes usage to teammates, weighting usage sponges heaviest', async () => { const r = await svc.getCascadeProjection('nba', 'Keldon Murray', 'SA', { teammates: [ - { player: 'Wembanyama', archetype: 'VOLUME SCORER' }, - { player: 'Bench Spark', archetype: 'USAGE SPONGE' }, - { player: 'Glue Guy', archetype: 'ROLE GLUE' }, + { player: 'Wembanyama', archetype: 'TORCH' }, + { player: 'Bench Spark', archetype: 'SURGE' }, + { player: 'Glue Guy', archetype: 'CONNECTOR' }, ], }); expect(r).toHaveLength(3); diff --git a/tests/unit/depthProxies.test.js b/tests/unit/depthProxies.test.js new file mode 100644 index 0000000..20fdb94 --- /dev/null +++ b/tests/unit/depthProxies.test.js @@ -0,0 +1,19 @@ +// Session 44 — Phase 5: depth chart Next proxies exist (were 404ing). + +const fs = require('fs'); +const path = require('path'); +const API = path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'stats'); + +describe('Depth chart Next proxy routes', () => { + it.each([ + ['lineup/[team]/route.ts', '/api/stats/lineup/'], + ['depth/[team]/route.ts', '/api/stats/depth/'], + ['cascade/[player]/route.ts', '/api/stats/cascade/'], + ])('%s exists and forwards to the Express backend', (rel, fwd) => { + const p = path.join(API, rel); + expect(fs.existsSync(p)).toBe(true); + const src = fs.readFileSync(p, 'utf8'); + expect(src).toContain('BACKEND_URL'); + expect(src).toContain(fwd); + }); +}); diff --git a/tests/unit/landingCopy.test.js b/tests/unit/landingCopy.test.js new file mode 100644 index 0000000..616e7ff --- /dev/null +++ b/tests/unit/landingCopy.test.js @@ -0,0 +1,19 @@ +// Session 44 — Phase 4: landing feature cards use user-facing language. + +const fs = require('fs'); +const path = require('path'); +const src = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'components', 'Features.tsx'), 'utf8'); + +describe('Landing feature cards', () => { + it('contains NO engineer-speak', () => { + for (const jargon of ['Point-biserial', 'Zone 14', 'ABS intelligence', 'Auto-calibrating', 'framing loss', 'redistribution-aware', 'Phi-coefficient', 'blind-spot detection']) { + expect(src).not.toContain(jargon); + } + }); + it('uses the rewritten user-facing titles', () => { + expect(src).toContain('Player DNA archetypes'); + expect(src).toContain('Self-improving model'); + expect(src).toContain('Lineup intel before tip-off'); + expect(src).toContain('Deep pitcher-batter matchups'); + }); +}); diff --git a/tests/unit/playerIntelCards.test.js b/tests/unit/playerIntelCards.test.js index 2a0ea0d..17c2c74 100644 --- a/tests/unit/playerIntelCards.test.js +++ b/tests/unit/playerIntelCards.test.js @@ -16,14 +16,14 @@ describe('gradeAdapter — Player Intelligence fields', () => { }); it('lifts a single archetype name into a one-segment blend', () => { - const f = buildIntelFields({ archetype: 'VOLUME SCORER' }); - expect(f.archetypeBlend).toEqual([{ archetype: 'VOLUME SCORER', weight: 1 }]); + const f = buildIntelFields({ archetype: 'TORCH' }); + expect(f.archetypeBlend).toEqual([{ archetype: 'TORCH', weight: 1 }]); }); it('passes through a full blend + propDNA + statContext + vyndrIntel', () => { const r = mapScanToGradeResult({ player: 'Wemby', stat: 'points', line: 26.5, grade: 'A', - archetype_blend: [{ archetype: 'TWO-WAY ANCHOR', weight: 0.6 }, { archetype: 'STRETCH BIG', weight: 0.4 }], + archetype_blend: [{ archetype: 'FORTRESS', weight: 0.6 }, { archetype: 'ARTILLERY', weight: 0.4 }], prop_dna: { reliable: ['points'], volatile: ['rebounds'] }, season_avg: 26.9, last10_avg: 28.4, vs_opp_avg: 30.1, form: 92, usage: '31.2%', matchup_grade: 'A', rest: '+2.4%', diff --git a/tests/unit/playerIntelWiring.test.js b/tests/unit/playerIntelWiring.test.js index 1e8ab0e..712b856 100644 --- a/tests/unit/playerIntelWiring.test.js +++ b/tests/unit/playerIntelWiring.test.js @@ -64,8 +64,8 @@ describe('getPlayerIntel with real stats (Session 43)', () => { expect(r.team).toBe('New York Yankees'); expect(r.season.length).toBeGreaterThan(0); // 34 HR + high K-rate → POWER PULL, not the empty-stats fallback. - expect(['POWER PULL', 'POWER SLUGGER', 'RUN PRODUCER']).toContain(r.archetype.primary.name); - expect(r.archetype.primary.name).not.toBe('UTILITY PLAYER'); + expect(['BOMBER', 'BOMBER', 'DRIVER']).toContain(r.archetype.primary.name); + expect(r.archetype.primary.name).not.toBe('FLEX'); }); it('classifies an ace pitcher from real stats', async () => { @@ -73,7 +73,7 @@ describe('getPlayerIntel with real stats (Session 43)', () => { cacheGet: async () => null, resolveStats: (name, sport) => svc.resolvePlayerStats(name, sport, { mlbAdapter: aceAdapter }), }); - expect(r.archetype.primary.name).toBe('ACE'); + expect(r.archetype.primary.name).toBe('ALPHA'); expect(r.found).toBe(true); }); diff --git a/tests/unit/playerProfile.test.js b/tests/unit/playerProfile.test.js index a2d8e7e..8bead54 100644 --- a/tests/unit/playerProfile.test.js +++ b/tests/unit/playerProfile.test.js @@ -23,9 +23,9 @@ describe('playerProfileAdapter', () => { expect(rows[1]).toMatchObject({ prop: 'Hits', state: 'VOLATILE', color: '#FFB347' }); }); it('writes a blend readout naming primary + secondary', () => { - const txt = adapter.blendReadout({ primary: { name: 'POWER PULL' }, secondary: { name: 'RUN PRODUCER' } }); - expect(txt).toContain('Power Pull'); - expect(txt).toContain('Run Producer'); + const txt = adapter.blendReadout({ primary: { name: 'BOMBER' }, secondary: { name: 'DRIVER' } }); + expect(txt).toContain('Bomber'); + expect(txt).toContain('Driver'); }); }); diff --git a/tests/unit/session44GradeIntel.test.js b/tests/unit/session44GradeIntel.test.js new file mode 100644 index 0000000..941e7e4 --- /dev/null +++ b/tests/unit/session44GradeIntel.test.js @@ -0,0 +1,43 @@ +// Session 44 — Phase 2: the grade card's intel sections actually populate. +// The full chain: engine (Object.assign intel) → analyze route (tierGating +// spread) → /api/scan proxy (spread) → scan page → gradeAdapter → card. +// The broken link was the scan page dropping the fields; this locks it. + +const fs = require('fs'); +const path = require('path'); +const ROOT = path.join(__dirname, '..', '..'); +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const { applyTierGating } = require('../../src/utils/tierGating'); + +describe('tierGating preserves engine intel fields', () => { + it('free tier keeps season_avg/form/etc. (spreads ...result)', () => { + const gated = applyTierGating({ grade: 'A', season_avg: 26.9, form: 92, matchup_grade: 'A', usage: '31%' }, 'free'); + expect(gated.season_avg).toBe(26.9); + expect(gated.form).toBe(92); + expect(gated.matchup_grade).toBe('A'); + }); + it('paid tier passes through unchanged', () => { + const out = applyTierGating({ grade: 'A', season_avg: 26.9, form: 92 }, 'desk'); + expect(out.season_avg).toBe(26.9); + }); +}); + +describe('scan page forwards intel fields to the grade card', () => { + const src = read('web/src/app/scan/page.tsx'); + it('passes the engine intel fields into mapScanToGradeResult', () => { + for (const f of ['season_avg: result.season_avg', 'last10_avg: result.last10_avg', 'form: result.form', 'usage: result.usage', 'matchup_grade: result.matchup_grade', 'rest: result.rest', 'archetype: result.archetype', 'archetype_blend: result.archetype_blend', 'prop_dna: result.prop_dna']) { + expect(src).toContain(f); + } + }); + it('ScanResponse type declares the intel fields', () => { + expect(src).toContain('season_avg?: number'); + expect(src).toContain('matchup_grade?: string'); + }); +}); + +describe('/api/scan proxy preserves engine fields', () => { + it('spreads ...data (does not whitelist)', () => { + const proxy = read('web/src/app/api/scan/route.ts'); + expect(proxy).toContain('...data'); + }); +}); diff --git a/tests/unit/slateAdapterStrips.test.js b/tests/unit/slateAdapterStrips.test.js index 8f439ea..6766836 100644 --- a/tests/unit/slateAdapterStrips.test.js +++ b/tests/unit/slateAdapterStrips.test.js @@ -22,9 +22,9 @@ describe('groupPropsByPlayer', () => { it('attaches an archetype when a lookup is provided', () => { const out = adapter.groupPropsByPlayer( [{ player: 'Riley', stat: 'Hits', line: 1.5, side: 'Over', grade: 'A' }], - () => ({ primary: 'POWER PULL' }), + () => ({ primary: 'BOMBER' }), ); - expect(out[0].archetype).toEqual({ primary: 'POWER PULL' }); + expect(out[0].archetype).toEqual({ primary: 'BOMBER' }); }); it('returns [] for empty / non-array input', () => { diff --git a/tests/unit/slateFreshness.test.js b/tests/unit/slateFreshness.test.js new file mode 100644 index 0000000..af8738a --- /dev/null +++ b/tests/unit/slateFreshness.test.js @@ -0,0 +1,34 @@ +// Session 44 — Phase 3: stale-game filtering. + +const fs = require('fs'); +const path = require('path'); +const adapter = require('../../web/src/lib/slateAdapter'); + +const NOW = new Date('2026-06-18T20:00:00Z').getTime(); +const hoursAgo = (h) => new Date(NOW - h * 3_600_000).toISOString(); + +describe('isRelevantGame', () => { + it('keeps upcoming and live games regardless of date', () => { + expect(adapter.isRelevantGame({ status: 'pre', gameTime: hoursAgo(-2) }, NOW)).toBe(true); + expect(adapter.isRelevantGame({ status: 'in', gameTime: hoursAgo(1) }, NOW)).toBe(true); + }); + it('drops a completed game older than 24h', () => { + expect(adapter.isRelevantGame({ status: 'post', gameTime: hoursAgo(120) }, NOW)).toBe(false); // 5 days + expect(adapter.isRelevantGame({ state: 'final', date: hoursAgo(30) }, NOW)).toBe(false); + }); + it('keeps a recently completed game (<24h)', () => { + expect(adapter.isRelevantGame({ status: 'post', gameTime: hoursAgo(3) }, NOW)).toBe(true); + }); + it('keeps games with an unparseable/missing date (degrade open)', () => { + expect(adapter.isRelevantGame({ status: 'post' }, NOW)).toBe(true); + expect(adapter.isRelevantGame({ status: 'post', gameTime: 'nonsense' }, NOW)).toBe(true); + }); +}); + +describe('Slate applies the freshness filter', () => { + it('filters games through isRelevantGame', () => { + const src = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'components', 'Slate.tsx'), 'utf8'); + expect(src).toContain('isRelevantGame'); + expect(src).toContain('games.filter((g) => isRelevantGame(g))'); + }); +}); diff --git a/web/public/sw.js b/web/public/sw.js index d3d66e2..158e3be 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -1,2 +1,2 @@ (()=>{"use strict";let e,t,a,s,r,i={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"serwist",runtime:"runtime",suffix:"u">typeof registration?registration.scope:""},n=e=>[i.prefix,e,i.suffix].filter(e=>e&&e.length>0).join("-"),c=e=>e||n(i.precache),o=e=>e||n(i.runtime);var l=class extends Error{details;constructor(e,t){super(((e,...t)=>{let a=e;return t.length>0&&(a+=` :: ${JSON.stringify(t)}`),a})(e,t)),this.name=e,this.details=t}};function h(e){return new Promise(t=>setTimeout(t,e))}let u=new Set;function d(e,t){let a=new URL(e);for(let e of t)a.searchParams.delete(e);return a.href}async function f(e,t,a,s){let r=d(t.url,a);if(t.url===r)return e.match(t,s);let i={...s,ignoreSearch:!0};for(let n of(await e.keys(t,i)))if(r===d(n.url,a))return e.match(n,s)}var p=class{promise;resolve;reject;constructor(){this.promise=new Promise((e,t)=>{this.resolve=e,this.reject=t})}};let m=async()=>{for(let e of u)await e()},w="-precache-",g=async(e,t=w)=>{let a=(await self.caches.keys()).filter(a=>a.includes(t)&&a.includes(self.registration.scope)&&a!==e);return await Promise.all(a.map(e=>self.caches.delete(e))),a},y=(e,t)=>{let a=t();return e.waitUntil(a),a},_=(e,t)=>t.some(t=>e instanceof t),b=new WeakMap,v=new WeakMap,R=new WeakMap,E={get(e,t,a){if(e instanceof IDBTransaction){if("done"===t)return b.get(e);if("store"===t)return a.objectStoreNames[1]?void 0:a.objectStore(a.objectStoreNames[0])}return q(e[t])},set:(e,t,a)=>(e[t]=a,!0),has:(e,t)=>e instanceof IDBTransaction&&("done"===t||"store"===t)||t in e};function q(e){if(e instanceof IDBRequest){let t;return t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("success",r),e.removeEventListener("error",i)},r=()=>{t(q(e.result)),s()},i=()=>{a(e.error),s()};e.addEventListener("success",r),e.addEventListener("error",i)}),R.set(t,e),t}if(v.has(e))return v.get(e);let t=function(e){if("function"==typeof e)return(r||(r=[IDBCursor.prototype.advance,IDBCursor.prototype.continue,IDBCursor.prototype.continuePrimaryKey])).includes(e)?function(...t){return e.apply(x(this),t),q(this.request)}:function(...t){return q(e.apply(x(this),t))};return(e instanceof IDBTransaction&&function(e){if(b.has(e))return;let t=new Promise((t,a)=>{let s=()=>{e.removeEventListener("complete",r),e.removeEventListener("error",i),e.removeEventListener("abort",i)},r=()=>{t(),s()},i=()=>{a(e.error||new DOMException("AbortError","AbortError")),s()};e.addEventListener("complete",r),e.addEventListener("error",i),e.addEventListener("abort",i)});b.set(e,t)}(e),_(e,s||(s=[IDBDatabase,IDBObjectStore,IDBIndex,IDBCursor,IDBTransaction])))?new Proxy(e,E):e}(e);return t!==e&&(v.set(e,t),R.set(t,e)),t}let x=e=>R.get(e);function D(e,t,{blocked:a,upgrade:s,blocking:r,terminated:i}={}){let n=indexedDB.open(e,t),c=q(n);return s&&n.addEventListener("upgradeneeded",e=>{s(q(n.result),e.oldVersion,e.newVersion,q(n.transaction),e)}),a&&n.addEventListener("blocked",e=>a(e.oldVersion,e.newVersion,e)),c.then(e=>{i&&e.addEventListener("close",()=>i()),r&&e.addEventListener("versionchange",e=>r(e.oldVersion,e.newVersion,e))}).catch(()=>{}),c}let S=["get","getKey","getAll","getAllKeys","count"],k=["put","add","delete","clear"],T=new Map;function P(e,t){if(!(e instanceof IDBDatabase&&!(t in e)&&"string"==typeof t))return;if(T.get(t))return T.get(t);let a=t.replace(/FromIndex$/,""),s=t!==a,r=k.includes(a);if(!(a in(s?IDBIndex:IDBObjectStore).prototype)||!(r||S.includes(a)))return;let i=async function(e,...t){let i=this.transaction(e,r?"readwrite":"readonly"),n=i.store;return s&&(n=n.index(t.shift())),(await Promise.all([n[a](...t),r&&i.done]))[0]};return T.set(t,i),i}E={...e=E,get:(t,a,s)=>P(t,a)||e.get(t,a,s),has:(t,a)=>!!P(t,a)||e.has(t,a)};let C=["continue","continuePrimaryKey","advance"],N={},I=new WeakMap,U=new WeakMap,L={get(e,t){if(!C.includes(t))return e[t];let a=N[t];return a||(a=N[t]=function(...e){I.set(this,U.get(this)[t](...e))}),a}};async function*A(...e){let t=this;if(t instanceof IDBCursor||(t=await t.openCursor(...e)),!t)return;let a=new Proxy(t,L);for(U.set(a,t),R.set(a,x(t));t;)yield a,t=await (I.get(a)||t.continue()),I.delete(a)}function O(e,t){return t===Symbol.asyncIterator&&_(e,[IDBIndex,IDBObjectStore,IDBCursor])||"iterate"===t&&_(e,[IDBIndex,IDBObjectStore])}E={...t=E,get:(e,a,s)=>O(e,a)?A:t.get(e,a,s),has:(e,a)=>O(e,a)||t.has(e,a)};let M=async(e,t)=>{let s=null;if(e.url&&(s=new URL(e.url).origin),s!==self.location.origin)throw new l("cross-origin-copy-response",{origin:s});let r=e.clone(),i={headers:new Headers(r.headers),status:r.status,statusText:r.statusText},n=t?t(i):i,c=!function(){if(void 0===a){let e=new Response("");if("body"in e)try{new Response(e.body),a=!0}catch{a=!1}a=!1}return a}()?await r.blob():r.body;return new Response(c,n)},B="requests",K="queueName";var F=class{_db=null;async addEntry(e){let t=(await this.getDb()).transaction(B,"readwrite",{durability:"relaxed"});await t.store.add(e),await t.done}async getFirstEntryId(){return(await (await this.getDb()).transaction(B).store.openCursor())?.value.id}async getAllEntriesByQueueName(e){return await (await this.getDb()).getAllFromIndex(B,K,IDBKeyRange.only(e))||[]}async getEntryCountByQueueName(e){return(await this.getDb()).countFromIndex(B,K,IDBKeyRange.only(e))}async deleteEntry(e){await (await this.getDb()).delete(B,e)}async getFirstEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"next")}async getLastEntryByQueueName(e){return await this.getEndEntryFromIndex(IDBKeyRange.only(e),"prev")}async getEndEntryFromIndex(e,t){return(await (await this.getDb()).transaction(B).store.index(K).openCursor(e,t))?.value}async getDb(){return this._db||(this._db=await D("serwist-background-sync",3,{upgrade:this._upgradeDb})),this._db}_upgradeDb(e,t){t>0&&t<3&&e.objectStoreNames.contains(B)&&e.deleteObjectStore(B),e.createObjectStore(B,{autoIncrement:!0,keyPath:"id"}).createIndex(K,K,{unique:!1})}},W=class{_queueName;_queueDb;constructor(e){this._queueName=e,this._queueDb=new F}async pushEntry(e){delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async unshiftEntry(e){let t=await this._queueDb.getFirstEntryId();t?e.id=t-1:delete e.id,e.queueName=this._queueName,await this._queueDb.addEntry(e)}async popEntry(){return this._removeEntry(await this._queueDb.getLastEntryByQueueName(this._queueName))}async shiftEntry(){return this._removeEntry(await this._queueDb.getFirstEntryByQueueName(this._queueName))}async getAll(){return await this._queueDb.getAllEntriesByQueueName(this._queueName)}async size(){return await this._queueDb.getEntryCountByQueueName(this._queueName)}async deleteEntry(e){await this._queueDb.deleteEntry(e)}async _removeEntry(e){return e&&await this.deleteEntry(e.id),e}};let j=["method","referrer","referrerPolicy","mode","credentials","cache","redirect","integrity","keepalive"];var H=class e{_requestData;static async fromRequest(t){let a={url:t.url,headers:{}};for(let e of("GET"!==t.method&&(a.body=await t.clone().arrayBuffer()),t.headers.forEach((e,t)=>{a.headers[t]=e}),j))void 0!==t[e]&&(a[e]=t[e]);return new e(a)}constructor(e){"navigate"===e.mode&&(e.mode="same-origin"),this._requestData=e}toObject(){let e=Object.assign({},this._requestData);return e.headers=Object.assign({},this._requestData.headers),e.body&&(e.body=e.body.slice(0)),e}toRequest(){return new Request(this._requestData.url,this._requestData)}clone(){return new e(this.toObject())}};let $="serwist-background-sync",V=new Set,Q=e=>{let t={request:new H(e.requestData).toRequest(),timestamp:e.timestamp};return e.metadata&&(t.metadata=e.metadata),t};var G=class{_name;_onSync;_maxRetentionTime;_queueStore;_forceSyncFallback;_syncInProgress=!1;_requestsAddedDuringSync=!1;constructor(e,{forceSyncFallback:t,onSync:a,maxRetentionTime:s}={}){if(V.has(e))throw new l("duplicate-queue-name",{name:e});V.add(e),this._name=e,this._onSync=a||this.replayRequests,this._maxRetentionTime=s||10080,this._forceSyncFallback=!!t,this._queueStore=new W(this._name),this._addSyncListener()}get name(){return this._name}async pushRequest(e){await this._addRequest(e,"push")}async unshiftRequest(e){await this._addRequest(e,"unshift")}async popRequest(){return this._removeRequest("pop")}async shiftRequest(){return this._removeRequest("shift")}async getAll(){let e=await this._queueStore.getAll(),t=Date.now(),a=[];for(let s of e){let e=60*this._maxRetentionTime*1e3;t-s.timestamp>e?await this._queueStore.deleteEntry(s.id):a.push(Q(s))}return a}async size(){return await this._queueStore.size()}async _addRequest({request:e,metadata:t,timestamp:a=Date.now()},s){let r={requestData:(await H.fromRequest(e.clone())).toObject(),timestamp:a};switch(t&&(r.metadata=t),s){case"push":await this._queueStore.pushEntry(r);break;case"unshift":await this._queueStore.unshiftEntry(r)}this._syncInProgress?this._requestsAddedDuringSync=!0:await this.registerSync()}async _removeRequest(e){let t,a=Date.now();switch(e){case"pop":t=await this._queueStore.popEntry();break;case"shift":t=await this._queueStore.shiftEntry()}if(t){let s=60*this._maxRetentionTime*1e3;return a-t.timestamp>s?this._removeRequest(e):Q(t)}}async replayRequests(){let e;for(;e=await this.shiftRequest();)try{await fetch(e.request.clone())}catch{throw await this.unshiftRequest(e),new l("queue-replay-failed",{name:this._name})}}async registerSync(){if("sync"in self.registration&&!this._forceSyncFallback)try{await self.registration.sync.register(`${$}:${this._name}`)}catch(e){}}_addSyncListener(){"sync"in self.registration&&!this._forceSyncFallback?self.addEventListener("sync",e=>{if(e.tag===`${$}:${this._name}`){let t=async()=>{let t;this._syncInProgress=!0;try{await this._onSync({queue:this})}catch(e){if(e instanceof Error)throw e}finally{this._requestsAddedDuringSync&&!(t&&!e.lastChance)&&await this.registerSync(),this._syncInProgress=!1,this._requestsAddedDuringSync=!1}};e.waitUntil(t())}}):this._onSync({queue:this})}static get _queueNames(){return V}},z=class{_queue;constructor(e,t){this._queue=new G(e,t)}async fetchDidFail({request:e}){await this._queue.pushRequest({request:e})}};let Y={cacheWillUpdate:async({response:e})=>200===e.status||0===e.status?e:null};function J(e){return"string"==typeof e?new Request(e):e}var X=class{event;request;url;params;_cacheKeys={};_strategy;_handlerDeferred;_extendLifetimePromises;_plugins;_pluginStateMap;constructor(e,t){for(const a of(this.event=t.event,this.request=t.request,t.url&&(this.url=t.url,this.params=t.params),this._strategy=e,this._handlerDeferred=new p,this._extendLifetimePromises=[],this._plugins=[...e.plugins],this._pluginStateMap=new Map,this._plugins))this._pluginStateMap.set(a,{});this.event.waitUntil(this._handlerDeferred.promise)}async fetch(e){let{event:t}=this,a=J(e),s=await this.getPreloadResponse();if(s)return s;let r=this.hasCallback("fetchDidFail")?a.clone():null;try{for(let e of this.iterateCallbacks("requestWillFetch"))a=await e({request:a.clone(),event:t})}catch(e){if(e instanceof Error)throw new l("plugin-error-request-will-fetch",{thrownErrorMessage:e.message})}let i=a.clone();try{let e;for(let s of(e=await fetch(a,"navigate"===a.mode?void 0:this._strategy.fetchOptions),this.iterateCallbacks("fetchDidSucceed")))e=await s({event:t,request:i,response:e});return e}catch(e){throw r&&await this.runCallbacks("fetchDidFail",{error:e,event:t,originalRequest:r.clone(),request:i.clone()}),e}}async fetchAndCachePut(e){let t=await this.fetch(e),a=t.clone();return this.waitUntil(this.cachePut(e,a)),t}async cacheMatch(e){let t,a=J(e),{cacheName:s,matchOptions:r}=this._strategy,i=await this.getCacheKey(a,"read"),n={...r,cacheName:s};for(let e of(t=await caches.match(i,n),this.iterateCallbacks("cachedResponseWillBeUsed")))t=await e({cacheName:s,matchOptions:r,cachedResponse:t,request:i,event:this.event})||void 0;return t}async cachePut(e,t){let a=J(e);await h(0);let s=await this.getCacheKey(a,"write");if(!t)throw new l("cache-put-with-no-response",{url:new URL(String(s.url),location.href).href.replace(RegExp(`^${location.origin}`),"")});let r=await this._ensureResponseSafeToCache(t);if(!r)return!1;let{cacheName:i,matchOptions:n}=this._strategy,c=await self.caches.open(i),o=this.hasCallback("cacheDidUpdate"),u=o?await f(c,s.clone(),["__WB_REVISION__"],n):null;try{await c.put(s,o?r.clone():r)}catch(e){if(e instanceof Error)throw"QuotaExceededError"===e.name&&await m(),e}for(let e of this.iterateCallbacks("cacheDidUpdate"))await e({cacheName:i,oldResponse:u,newResponse:r.clone(),request:s,event:this.event});return!0}async getCacheKey(e,t){let a=`${e.url} | ${t}`;if(!this._cacheKeys[a]){let s=e;for(let e of this.iterateCallbacks("cacheKeyWillBeUsed"))s=J(await e({mode:t,request:s,event:this.event,params:this.params}));this._cacheKeys[a]=s}return this._cacheKeys[a]}hasCallback(e){for(let t of this._strategy.plugins)if(e in t)return!0;return!1}async runCallbacks(e,t){for(let a of this.iterateCallbacks(e))await a(t)}*iterateCallbacks(e){for(let t of this._strategy.plugins)if("function"==typeof t[e]){let a=this._pluginStateMap.get(t),s=s=>{let r={...s,state:a};return t[e](r)};yield s}}waitUntil(e){return this._extendLifetimePromises.push(e),e}async doneWaiting(){let e;for(;e=this._extendLifetimePromises.shift();)await e}destroy(){this._handlerDeferred.resolve(null)}async getPreloadResponse(){if(this.event instanceof FetchEvent&&"navigate"===this.event.request.mode&&"preloadResponse"in this.event)try{let e=await this.event.preloadResponse;if(e)return e}catch(e){return}}async _ensureResponseSafeToCache(e){let t=e,a=!1;for(let e of this.iterateCallbacks("cacheWillUpdate"))if(t=await e({request:this.request,response:t,event:this.event})||void 0,a=!0,!t)break;return!a&&t&&200!==t.status&&(t=void 0),t}},Z=class{cacheName;plugins;fetchOptions;matchOptions;constructor(e={}){this.cacheName=o(e.cacheName),this.plugins=e.plugins||[],this.fetchOptions=e.fetchOptions,this.matchOptions=e.matchOptions}handle(e){let[t]=this.handleAll(e);return t}handleAll(e){e instanceof FetchEvent&&(e={event:e,request:e.request});let t=e.event,a="string"==typeof e.request?new Request(e.request):e.request,s=new X(this,e.url?{event:t,request:a,url:e.url,params:e.params}:{event:t,request:a}),r=this._getResponse(s,a,t);return[r,this._awaitComplete(r,s,a,t)]}async _getResponse(e,t,a){let s;await e.runCallbacks("handlerWillStart",{event:a,request:t});try{if(s=await this._handle(t,e),void 0===s||"error"===s.type)throw new l("no-response",{url:t.url})}catch(r){if(r instanceof Error){for(let i of e.iterateCallbacks("handlerDidError"))if(void 0!==(s=await i({error:r,event:a,request:t})))break}if(!s)throw r}for(let r of e.iterateCallbacks("handlerWillRespond"))s=await r({event:a,request:t,response:s});return s}async _awaitComplete(e,t,a,s){let r,i;try{r=await e}catch{}try{await t.runCallbacks("handlerDidRespond",{event:s,request:a,response:r}),await t.doneWaiting()}catch(e){e instanceof Error&&(i=e)}if(await t.runCallbacks("handlerDidComplete",{event:s,request:a,response:r,error:i}),t.destroy(),i)throw i}},ee=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this.plugins.some(e=>"cacheWillUpdate"in e)||this.plugins.unshift(Y),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s=[],r=[];if(this._networkTimeoutSeconds){let{id:i,promise:n}=this._getTimeoutPromise({request:e,logs:s,handler:t});a=i,r.push(n)}let i=this._getNetworkPromise({timeoutId:a,request:e,logs:s,handler:t});r.push(i);let n=await t.waitUntil((async()=>await t.waitUntil(Promise.race(r))||await i)());if(!n)throw new l("no-response",{url:e.url});return n}_getTimeoutPromise({request:e,logs:t,handler:a}){let s;return{promise:new Promise(t=>{s=setTimeout(async()=>{t(await a.cacheMatch(e))},1e3*this._networkTimeoutSeconds)}),id:s}}async _getNetworkPromise({timeoutId:e,request:t,logs:a,handler:s}){let r,i;try{i=await s.fetchAndCachePut(t)}catch(e){e instanceof Error&&(r=e)}return e&&clearTimeout(e),(r||!i)&&(i=await s.cacheMatch(t)),i}},et=class extends Z{_networkTimeoutSeconds;constructor(e={}){super(e),this._networkTimeoutSeconds=e.networkTimeoutSeconds||0}async _handle(e,t){let a,s;try{let a=[t.fetch(e)];if(this._networkTimeoutSeconds){let e=h(1e3*this._networkTimeoutSeconds);a.push(e)}if(!(s=await Promise.race(a)))throw Error(`Timed out the network response after ${this._networkTimeoutSeconds} seconds.`)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}};let ea=e=>e&&"object"==typeof e?e:{handle:e};var es=class{handler;match;method;catchHandler;constructor(e,t,a="GET"){this.handler=ea(t),this.match=e,this.method=a}setCatchHandler(e){this.catchHandler=ea(e)}},er=class e extends Z{_fallbackToNetwork;static defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:e})=>!e||e.status>=400?null:e};static copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:e})=>e.redirected?await M(e):e};constructor(t={}){t.cacheName=c(t.cacheName),super(t),this._fallbackToNetwork=!1!==t.fallbackToNetwork,this.plugins.push(e.copyRedirectedCacheableResponsesPlugin)}async _handle(e,t){let a=await t.getPreloadResponse();if(a)return a;let s=await t.cacheMatch(e);return s||(t.event&&"install"===t.event.type?await this._handleInstall(e,t):await this._handleFetch(e,t))}async _handleFetch(e,t){let a,s=t.params||{};if(this._fallbackToNetwork){let r=s.integrity,i=e.integrity,n=!i||i===r;a=await t.fetch(new Request(e,{integrity:"no-cors"!==e.mode?i||r:void 0})),r&&n&&"no-cors"!==e.mode&&(this._useDefaultCacheabilityPluginIfNeeded(),await t.cachePut(e,a.clone()))}else throw new l("missing-precache-entry",{cacheName:this.cacheName,url:e.url});return a}async _handleInstall(e,t){this._useDefaultCacheabilityPluginIfNeeded();let a=await t.fetch(e);if(!await t.cachePut(e,a.clone()))throw new l("bad-precaching-response",{url:e.url,status:a.status});return a}_useDefaultCacheabilityPluginIfNeeded(){let t=null,a=0;for(let[s,r]of this.plugins.entries())r!==e.copyRedirectedCacheableResponsesPlugin&&(r===e.defaultPrecacheCacheabilityPlugin&&(t=s),r.cacheWillUpdate&&a++);0===a?this.plugins.push(e.defaultPrecacheCacheabilityPlugin):a>1&&null!==t&&this.plugins.splice(t,1)}},ei=class extends es{_allowlist;_denylist;constructor(e,{allowlist:t=[/./],denylist:a=[]}={}){super(e=>this._match(e),e),this._allowlist=t,this._denylist=a}_match({url:e,request:t}){if(t&&"navigate"!==t.mode)return!1;let a=e.pathname+e.search;for(let e of this._denylist)if(e.test(a))return!1;return!!this._allowlist.some(e=>e.test(a))}},en=class extends es{constructor(e,t,a){super(({url:t})=>{let a=e.exec(t.href);if(a)return t.origin!==location.origin&&0!==a.index?void 0:a.slice(1)},t,a)}};let ec=e=>{if(!e)throw new l("add-to-cache-list-unexpected-type",{entry:e});if("string"==typeof e){let t=new URL(e,location.href);return{cacheKey:t.href,url:t.href}}let{revision:t,url:a}=e;if(!a)throw new l("add-to-cache-list-unexpected-type",{entry:e});if(!t){let e=new URL(a,location.href);return{cacheKey:e.href,url:e.href}}let s=new URL(a,location.href),r=new URL(a,location.href);return s.searchParams.set("__WB_REVISION__",t),{cacheKey:s.href,url:r.href}};var eo=class{updatedURLs=[];notUpdatedURLs=[];handlerWillStart=async({request:e,state:t})=>{t&&(t.originalRequest=e)};cachedResponseWillBeUsed=async({event:e,state:t,cachedResponse:a})=>{if("install"===e.type&&t?.originalRequest&&t.originalRequest instanceof Request){let e=t.originalRequest.url;a?this.notUpdatedURLs.push(e):this.updatedURLs.push(e)}return a}};let el=async(e,t,a)=>{let s=t.map((e,t)=>({index:t,item:e})),r=async e=>{let t=[];for(;;){let r=s.pop();if(!r)return e(t);let i=await a(r.item);t.push({result:i,index:r.index})}},i=Array.from({length:e},()=>new Promise(r));return(await Promise.all(i)).flat().sort((e,t)=>e.indexe.result)};"u">typeof navigator&&/^((?!chrome|android).)*safari/i.test(navigator.userAgent);let eh="cache-entries",eu=e=>{let t=new URL(e,location.href);return t.hash="",t.href};var ed=class{_cacheName;_db=null;constructor(e){this._cacheName=e}_getId(e){return`${this._cacheName}|${eu(e)}`}_upgradeDb(e){let t=e.createObjectStore(eh,{keyPath:"id"});t.createIndex("cacheName","cacheName",{unique:!1}),t.createIndex("timestamp","timestamp",{unique:!1})}_upgradeDbAndDeleteOldDbs(e){this._upgradeDb(e),this._cacheName&&function(e,{blocked:t}={}){let a=indexedDB.deleteDatabase(e);t&&a.addEventListener("blocked",e=>t(e.oldVersion,e)),q(a).then(()=>void 0)}(this._cacheName)}async setTimestamp(e,t){e=eu(e);let a={id:this._getId(e),cacheName:this._cacheName,url:e,timestamp:t},s=(await this.getDb()).transaction(eh,"readwrite",{durability:"relaxed"});await s.store.put(a),await s.done}async getTimestamp(e){return(await (await this.getDb()).get(eh,this._getId(e)))?.timestamp}async expireEntries(e,t){let a=await (await this.getDb()).transaction(eh,"readwrite").store.index("timestamp").openCursor(null,"prev"),s=[],r=0;for(;a;){let i=a.value;i.cacheName===this._cacheName&&(e&&i.timestamp=t?(a.delete(),s.push(i.url)):r++),a=await a.continue()}return s}async getDb(){return this._db||(this._db=await D("serwist-expiration",1,{upgrade:this._upgradeDbAndDeleteOldDbs.bind(this)})),this._db}},ef=class{_isRunning=!1;_rerunRequested=!1;_maxEntries;_maxAgeSeconds;_matchOptions;_cacheName;_timestampModel;constructor(e,t={}){this._maxEntries=t.maxEntries,this._maxAgeSeconds=t.maxAgeSeconds,this._matchOptions=t.matchOptions,this._cacheName=e,this._timestampModel=new ed(e)}async expireEntries(){if(this._isRunning){this._rerunRequested=!0;return}this._isRunning=!0;let e=this._maxAgeSeconds?Date.now()-1e3*this._maxAgeSeconds:0,t=await this._timestampModel.expireEntries(e,this._maxEntries),a=await self.caches.open(this._cacheName);for(let e of t)await a.delete(e,this._matchOptions);this._isRunning=!1,this._rerunRequested&&(this._rerunRequested=!1,this.expireEntries())}async updateTimestamp(e){await this._timestampModel.setTimestamp(e,Date.now())}async isURLExpired(e){if(!this._maxAgeSeconds)return!1;let t=await this._timestampModel.getTimestamp(e),a=Date.now()-1e3*this._maxAgeSeconds;return void 0===t||t{u.add(e)})(()=>this.deleteCacheAndMetadata())}_getCacheExpiration(e){if(e===o())throw new l("expire-custom-caches-only");let t=this._cacheExpirations.get(e);return t||(t=new ef(e,this._config),this._cacheExpirations.set(e,t)),t}cachedResponseWillBeUsed({event:e,cacheName:t,request:a,cachedResponse:s}){if(!s)return null;let r=this._isResponseDateFresh(s),i=this._getCacheExpiration(t),n="last-used"===this._config.maxAgeFrom,c=(async()=>{n&&await i.updateTimestamp(a.url),await i.expireEntries()})();try{e.waitUntil(c)}catch{}return r?s:null}_isResponseDateFresh(e){if("last-used"===this._config.maxAgeFrom)return!0;let t=Date.now();if(!this._config.maxAgeSeconds)return!0;let a=this._getDateHeaderTimestamp(e);return null===a||a>=t-1e3*this._config.maxAgeSeconds}_getDateHeaderTimestamp(e){if(!e.headers.has("date"))return null;let t=new Date(e.headers.get("date")).getTime();return Number.isNaN(t)?null:t}async cacheDidUpdate({cacheName:e,request:t}){let a=this._getCacheExpiration(e);await a.updateTimestamp(t.url),await a.expireEntries()}async deleteCacheAndMetadata(){for(let[e,t]of this._cacheExpirations)await self.caches.delete(e),await t.delete();this._cacheExpirations=new Map}};let em=/^\/(\w+\/)?collect/,ew=({serwist:e,cacheName:t,...a})=>{let s,r,c=t||n(i.googleAnalytics),o=new z("serwist-google-analytics",{maxRetentionTime:2880,onSync:async({queue:e})=>{let t;for(;t=await e.shiftRequest();){let{request:s,timestamp:r}=t,i=new URL(s.url);try{let e="POST"===s.method?new URLSearchParams(await s.clone().text()):i.searchParams,t=r-(Number(e.get("qt"))||0),n=Date.now()-t;if(e.set("qt",String(n)),a.parameterOverrides)for(let t of Object.keys(a.parameterOverrides)){let s=a.parameterOverrides[t];e.set(t,s)}"function"==typeof a.hitFilter&&a.hitFilter.call(null,e),await fetch(new Request(i.origin+i.pathname,{body:e.toString(),method:"POST",mode:"cors",credentials:"omit",headers:{"Content-Type":"text/plain"}}))}catch(a){throw await e.unshiftRequest(t),a}}}});for(let t of[new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtm.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.google-analytics.com"===e.hostname&&"/analytics.js"===e.pathname,new ee({cacheName:c}),"GET"),new es(({url:e})=>"www.googletagmanager.com"===e.hostname&&"/gtag/js"===e.pathname,new ee({cacheName:c}),"GET"),new es(s=({url:e})=>"www.google-analytics.com"===e.hostname&&em.test(e.pathname),r=new et({plugins:[o]}),"GET"),new es(s,r,"POST")])e.registerRoute(t)};var eg=class{_fallbackUrls;_serwist;constructor({fallbackUrls:e,serwist:t}){this._fallbackUrls=e,this._serwist=t}async handlerDidError(e){for(let t of this._fallbackUrls)if("string"==typeof t){let e=await this._serwist.matchPrecache(t);if(void 0!==e)return e}else if(t.matcher(e)){let e=await this._serwist.matchPrecache(t.url);if(void 0!==e)return e}}},ey=class extends Z{async _handle(e,t){let a,s=await t.cacheMatch(e);if(s);else try{s=await t.fetchAndCachePut(e)}catch(e){e instanceof Error&&(a=e)}if(!s)throw new l("no-response",{url:e.url,error:a});return s}},e_=class extends es{constructor(e,t){super(({request:a})=>{let s=e.getUrlsToPrecacheKeys();for(let r of function*(e,{directoryIndex:t="index.html",ignoreURLParametersMatching:a=[/^utm_/,/^fbclid$/],cleanURLs:s=!0,urlManipulation:r}={}){let i=new URL(e,location.href);i.hash="",yield i.href;let n=((e,t=[])=>{for(let a of[...e.searchParams.keys()])t.some(e=>e.test(a))&&e.searchParams.delete(a);return e})(i,a);if(yield n.href,t&&n.pathname.endsWith("/")){let e=new URL(n.href);e.pathname+=t,yield e.href}if(s){let e=new URL(n.href);e.pathname+=".html",yield e.href}if(r)for(let e of r({url:i}))yield e.href}(a.url,t)){let t=s.get(r);if(t)return{cacheKey:t,integrity:e.getIntegrityForPrecacheKey(t)}}},e.precacheStrategy)}},eb=class{_precacheController;constructor({precacheController:e}){this._precacheController=e}cacheKeyWillBeUsed=async({request:e,params:t})=>{let a=t?.cacheKey||this._precacheController.getPrecacheKeyForUrl(e.url);return a?new Request(a,{headers:e.headers}):e}},ev=class{_urlsToCacheKeys=new Map;_urlsToCacheModes=new Map;_cacheKeysToIntegrities=new Map;_concurrentPrecaching;_precacheStrategy;_routes;_defaultHandlerMap;_catchHandler;_requestRules;constructor({precacheEntries:e,precacheOptions:t,skipWaiting:a=!1,importScripts:s,navigationPreload:r=!1,cacheId:n,clientsClaim:o=!1,runtimeCaching:l,offlineAnalyticsConfig:h,disableDevLogs:u=!1,fallbacks:d,requestRules:f}={}){const{precacheStrategyOptions:p,precacheRouteOptions:m,precacheMiscOptions:w}=((e,t={})=>{let{cacheName:a,plugins:s=[],fetchOptions:r,matchOptions:i,fallbackToNetwork:n,directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u,cleanupOutdatedCaches:d,concurrency:f=10,navigateFallback:p,navigateFallbackAllowlist:m,navigateFallbackDenylist:w}=t??{};return{precacheStrategyOptions:{cacheName:c(a),plugins:[...s,new eb({precacheController:e})],fetchOptions:r,matchOptions:i,fallbackToNetwork:n},precacheRouteOptions:{directoryIndex:o,ignoreURLParametersMatching:l,cleanURLs:h,urlManipulation:u},precacheMiscOptions:{cleanupOutdatedCaches:d,concurrency:f,navigateFallback:p,navigateFallbackAllowlist:m,navigateFallbackDenylist:w}}})(this,t);if(this._concurrentPrecaching=w.concurrency,this._precacheStrategy=new er(p),this._routes=new Map,this._defaultHandlerMap=new Map,this._requestRules=f,this.handleInstall=this.handleInstall.bind(this),this.handleActivate=this.handleActivate.bind(this),this.handleFetch=this.handleFetch.bind(this),this.handleCache=this.handleCache.bind(this),s&&s.length>0&&self.importScripts(...s),r&&self.registration?.navigationPreload&&self.addEventListener("activate",e=>{e.waitUntil(self.registration.navigationPreload.enable().then(()=>{}))}),void 0!==n&&(e=>{var t=e;for(let e of Object.keys(i))(e=>{let a=t[e];"string"==typeof a&&(i[e]=a)})(e)})({prefix:n}),a?self.skipWaiting():self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),o&&self.addEventListener("activate",()=>self.clients.claim()),e&&e.length>0&&this.addToPrecacheList(e),w.cleanupOutdatedCaches&&(e=>{self.addEventListener("activate",t=>{t.waitUntil(g(c(e)).then(e=>{}))})})(p.cacheName),this.registerRoute(new e_(this,m)),w.navigateFallback&&this.registerRoute(new ei(this.createHandlerBoundToUrl(w.navigateFallback),{allowlist:w.navigateFallbackAllowlist,denylist:w.navigateFallbackDenylist})),void 0!==h&&("boolean"==typeof h?h&&ew({serwist:this}):ew({...h,serwist:this})),void 0!==l){if(void 0!==d){const e=new eg({fallbackUrls:d.entries,serwist:this});l.forEach(t=>{t.handler instanceof Z&&!t.handler.plugins.some(e=>"handlerDidError"in e)&&t.handler.plugins.push(e)})}for(const e of l)this.registerCapture(e.matcher,e.handler,e.method)}u&&(self.__WB_DISABLE_DEV_LOGS=!0)}get precacheStrategy(){return this._precacheStrategy}get routes(){return this._routes}addEventListeners(){self.addEventListener("install",this.handleInstall),self.addEventListener("activate",this.handleActivate),self.addEventListener("fetch",this.handleFetch),self.addEventListener("message",this.handleCache)}addToPrecacheList(e){let t=[];for(let a of e){"string"==typeof a?t.push(a):a&&!a.integrity&&void 0===a.revision&&t.push(a.url);let{cacheKey:e,url:s}=ec(a),r="string"!=typeof a&&a.revision?"reload":"default";if(this._urlsToCacheKeys.has(s)&&this._urlsToCacheKeys.get(s)!==e)throw new l("add-to-cache-list-conflicting-entries",{firstEntry:this._urlsToCacheKeys.get(s),secondEntry:e});if("string"!=typeof a&&a.integrity){if(this._cacheKeysToIntegrities.has(e)&&this._cacheKeysToIntegrities.get(e)!==a.integrity)throw new l("add-to-cache-list-conflicting-integrities",{url:s});this._cacheKeysToIntegrities.set(e,a.integrity)}this._urlsToCacheKeys.set(s,e),this._urlsToCacheModes.set(s,r)}t.length>0&&console.warn(`Serwist is precaching URLs without revision info: ${t.join(", ")} -This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new en(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let i,n=r.match({url:e,sameOrigin:t,request:a,event:s});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:r,params:i}}return{}}};let eR="/offline",eE=["pages","api-responses","next-static","static-media","fallback","offline-fallback"],eq=[{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/api/"),handler:new ee({cacheName:"api-responses",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:100,maxAgeSeconds:3600})]})},{matcher:({request:e})=>"navigate"===e.mode,handler:new ee({cacheName:"pages",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400}),{handlerDidError:async()=>await caches.match(eR)||Response.error()}]})},{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/_next/static/"),handler:new ey({cacheName:"next-static",plugins:[new ep({maxEntries:200,maxAgeSeconds:2592e3})]})},{matcher:({url:e})=>e.pathname.startsWith("/images/")||e.pathname.startsWith("/icons/")||/\.(?:png|jpe?g|gif|svg|webp|ico|woff2?)$/.test(e.pathname),handler:new ey({cacheName:"static-media",plugins:[new ep({maxEntries:100,maxAgeSeconds:2592e3})]})},{matcher:()=>!0,handler:new ee({cacheName:"fallback",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400})]})}];new ev({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/1079-304a546a4a673437.js'},{'revision':null,'url':'/_next/static/chunks/1896-359ed2e68a8c4f1d.js'},{'revision':null,'url':'/_next/static/chunks/1958-56cbbb8dba15fd2f.js'},{'revision':null,'url':'/_next/static/chunks/2346-36e36720a5f6591a.js'},{'revision':null,'url':'/_next/static/chunks/3268-0a7190b483de059a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-e356ca5ba0218e27.js'},{'revision':null,'url':'/_next/static/chunks/52774a7f.f2ab7a9b4b8ba576.js'},{'revision':null,'url':'/_next/static/chunks/5838-7e0aa455e9f24259.js'},{'revision':null,'url':'/_next/static/chunks/7117-207a7246fa92b547.js'},{'revision':null,'url':'/_next/static/chunks/7363-8cb635f76a4ec773.js'},{'revision':null,'url':'/_next/static/chunks/7602.2868ff821d53ee09.js'},{'revision':null,'url':'/_next/static/chunks/7918-9cfe56e3ee27ed27.js'},{'revision':null,'url':'/_next/static/chunks/8500-f62a38ff68ab7f42.js'},{'revision':null,'url':'/_next/static/chunks/983-3a3522324101948e.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-9623f3245f088d02.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/about/page-b78d804f2759f7dc.js'},{'revision':null,'url':'/_next/static/chunks/app/account/page-9300f08c33aa8887.js'},{'revision':null,'url':'/_next/static/chunks/app/admin/page-b3b6af59ae9681bb.js'},{'revision':null,'url':'/_next/static/chunks/app/api/admin/stats/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5B...path%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/content/%5B...path%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/gamelines/%5Bsport%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hero-prop/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hotlist/%5Bsport%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/lines/%5B...path%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/calculate/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/leaders/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/player/%5Bname%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/streaks/%5Bsport%5D/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-e6df9771a50edb87.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/compare/page-5e631b0c698c6f5f.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-c83ad213d291ab56.js'},{'revision':null,'url':'/_next/static/chunks/app/explore/page-8856c642532e2773.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-a251f52b4206e8d1.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-ac9d9a479ea7ab89.js'},{'revision':null,'url':'/_next/static/chunks/app/help/page-03c6534f006d09da.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-772d69c9ecfe1503.js'},{'revision':null,'url':'/_next/static/chunks/app/invite/page-e1e3f46af5f1e44b.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-94ab8755a348e2ac.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-47459e4f664b707a.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-fb8804a2532d10b8.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-cbada49ae96a7527.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-c8c8c338b54c2369.js'},{'revision':null,'url':'/_next/static/chunks/app/notifications/page-b78d804f2759f7dc.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-a7a57ff039afc1af.js'},{'revision':null,'url':'/_next/static/chunks/app/page-b9dfe304a1c026c6.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/page-a9a1a22752478255.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-049fa136ce667e3e.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-d607627acb1b73ab.js'},{'revision':null,'url':'/_next/static/chunks/app/report/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-e6281db3192c5e64.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/page-25678f7d56db0a16.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-697d02aa2d05cf7e.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-b6917fd30dbecebe.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-5a64e650908111d7.js'},{'revision':null,'url':'/_next/static/chunks/app/terminal/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-e23baec2b3735bb1.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-3702e818a6853705.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-24eaa80743986159.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-ea358cb8f714f1ea.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-9df376f065215168.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-259277230533599f.js'},{'revision':null,'url':'/_next/static/chunks/framework-95da69ac6843d788.js'},{'revision':null,'url':'/_next/static/chunks/main-a354262253293123.js'},{'revision':null,'url':'/_next/static/chunks/main-app-4231d5f500f33972.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-6116448349d23e8a.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-756d787dba63aa4d.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-6116448349d23e8a.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-42b9616650231776.js'},{'revision':null,'url':'/_next/static/css/e3440a4469d87735.css'},{'revision':'c2fa16ed4e1cfb8b95e798b315266167','url':'/_next/static/cvhB694e4PVHM0J1NVT0a/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/cvhB694e4PVHM0J1NVT0a/_ssgManifest.js'},{'revision':'cd34c7793d9776838308cfbe5d93922b','url':'/_next/static/media/011e180705008d6f-s.woff2'},{'revision':'324703f03c390d2e2a4f387de85fe63d','url':'/_next/static/media/0aa834ed78bf6d07-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'7de6d31e5076eb437418b6e01998ee12','url':'/_next/static/media/20535187d867b7b9-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'0d11e07b5aaafda6ae2efc53256932ec','url':'/_next/static/media/37786be940ec402b-s.p.woff2'},{'revision':'5de0bff80f9f432b5cf52c1c2f80fe20','url':'/_next/static/media/46e154b2fcbd6033-s.woff2'},{'revision':'6041b3192dc3e1a50141d5d418a24199','url':'/_next/static/media/5356a6a4f2c8c8d8-s.woff2'},{'revision':'f572f7b57d27ec7fd8373961f0b762b0','url':'/_next/static/media/58f386aa6b1a2a92-s.woff2'},{'revision':'e829cbe042e8b2b2e6b99c0eec9e230d','url':'/_next/static/media/656feb427634a431-s.woff2'},{'revision':'54f02056e07c55023315568c637e3a96','url':'/_next/static/media/67957d42bae0796d-s.woff2'},{'revision':'e220ad1849c282590e47db0ff88fc3f3','url':'/_next/static/media/704b853f32d191d5-s.woff2'},{'revision':'662cde3543f3375874e26c26845cec22','url':'/_next/static/media/73cb51aac9c97f90-s.woff2'},{'revision':'afac1fab355fe8db5c2dcaa7623dffb3','url':'/_next/static/media/7ba5fb2a8c88521c-s.woff2'},{'revision':'c94e6e6c23e789fcb0fc60d790c9d2c1','url':'/_next/static/media/886030b0b59bc5a7-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'17a5b2a15bf5647b49cadb9a3bb02e1c','url':'/_next/static/media/92eeb95d069020cc-s.woff2'},{'revision':'4a4e74bed5809194e4bc6538eb1a1e30','url':'/_next/static/media/939c4f875ee75fbb-s.woff2'},{'revision':'6708425c446b05de1c2d471a0eaf4099','url':'/_next/static/media/98e207f02528a563-s.p.woff2'},{'revision':'13dface50f6dc0bff1015561c84119e6','url':'/_next/static/media/991629005c80bdf1-s.woff2'},{'revision':'216ee1a35b060106ed2852cdd8c026c7','url':'/_next/static/media/99dcf268bda04fe5-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'782150e6836b9b074d1a798807adcb18','url':'/_next/static/media/bb3ef058b751a6ad-s.p.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'75fa2e527eaae805bbf44e3378d1232c','url':'/_next/static/media/d26bbd13d6b70f89-s.woff2'},{'revision':'0ba762bde65aaa682fcc089f8c57288e','url':'/_next/static/media/d29838c109ef09b4-s.woff2'},{'revision':'814fe1615d4dbc7bbd92d26a6bda0905','url':'/_next/static/media/d3ebbfd689654d3a-s.p.woff2'},{'revision':'e71a597e376ed91862e822b5a5de8c04','url':'/_next/static/media/db96af6b531dc71f-s.p.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'9fb609fe01d739bb9df558e1fba2498e','url':'/_next/static/media/e40af3453d7c920a-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'de1d49f26130d43f91829088086625f4','url':'/_next/static/media/ef4d5661765d0e49-s.woff2'},{'revision':'0f8d347d49960d05c9430d83e49edeb7','url':'/_next/static/media/f911b923c6adde36-s.woff2'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'bf670b1fafa1b3127bd50abe86d723b9','url':'/images/player-silhouette.svg'},{'revision':'d692f690fbd1564f090131e02be5ba9c','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("install",e=>{e.waitUntil(caches.open("offline-fallback").then(e=>e.add(eR)).catch(()=>{}))}),self.addEventListener("activate",e=>{e.waitUntil(caches.keys().then(e=>Promise.all(e.filter(e=>!eE.includes(e)&&!e.startsWith("serwist")).map(e=>(console.log("[SW] deleting stale cache:",e),caches.delete(e))))))}),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:i="/",tag:n="vyndr-notification"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",tag:n,data:{url:i}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file +This is generally NOT safe. Learn more at https://bit.ly/wb-precache`)}handleInstall(e){return this.registerRequestRules(e),y(e,async()=>{let t=new eo;this.precacheStrategy.plugins.push(t),await el(this._concurrentPrecaching,Array.from(this._urlsToCacheKeys.entries()),async([t,a])=>{let s=this._cacheKeysToIntegrities.get(a),r=this._urlsToCacheModes.get(t),i=new Request(t,{integrity:s,cache:r,credentials:"same-origin"});await Promise.all(this.precacheStrategy.handleAll({event:e,request:i,url:new URL(i.url),params:{cacheKey:a}}))});let{updatedURLs:a,notUpdatedURLs:s}=t;return{updatedURLs:a,notUpdatedURLs:s}})}async registerRequestRules(e){if(this._requestRules&&e?.addRoutes)try{await e.addRoutes(this._requestRules),this._requestRules=void 0}catch(e){throw e}}handleActivate(e){return y(e,async()=>{let e=await self.caches.open(this.precacheStrategy.cacheName),t=await e.keys(),a=new Set(this._urlsToCacheKeys.values()),s=[];for(let r of t)a.has(r.url)||(await e.delete(r),s.push(r.url));return{deletedCacheRequests:s}})}handleFetch(e){let{request:t}=e,a=this.handleRequest({request:t,event:e});a&&e.respondWith(a)}handleCache(e){if(e.data&&"CACHE_URLS"===e.data.type){let{payload:t}=e.data,a=Promise.all(t.urlsToCache.map(t=>{let a;return a="string"==typeof t?new Request(t):new Request(...t),this.handleRequest({request:a,event:e})}));e.waitUntil(a),e.ports?.[0]&&a.then(()=>e.ports[0].postMessage(!0))}}setDefaultHandler(e,t="GET"){this._defaultHandlerMap.set(t,ea(e))}setCatchHandler(e){this._catchHandler=ea(e)}registerCapture(e,t,a){let s=((e,t,a)=>{if("string"==typeof e){let s=new URL(e,location.href);return new es(({url:e})=>e.href===s.href,t,a)}if(e instanceof RegExp)return new en(e,t,a);if("function"==typeof e)return new es(e,t,a);if(e instanceof es)return e;throw new l("unsupported-route-type",{moduleName:"serwist",funcName:"parseRoute",paramName:"capture"})})(e,t,a);return this.registerRoute(s),s}registerRoute(e){this._routes.has(e.method)||this._routes.set(e.method,[]),this._routes.get(e.method).push(e)}unregisterRoute(e){if(!this._routes.has(e.method))throw new l("unregister-route-but-not-found-with-method",{method:e.method});let t=this._routes.get(e.method).indexOf(e);if(t>-1)this._routes.get(e.method).splice(t,1);else throw new l("unregister-route-route-not-registered")}getUrlsToPrecacheKeys(){return this._urlsToCacheKeys}getPrecachedUrls(){return[...this._urlsToCacheKeys.keys()]}getPrecacheKeyForUrl(e){let t=new URL(e,location.href);return this._urlsToCacheKeys.get(t.href)}getIntegrityForPrecacheKey(e){return this._cacheKeysToIntegrities.get(e)}async matchPrecache(e){let t=e instanceof Request?e.url:e,a=this.getPrecacheKeyForUrl(t);if(a)return(await self.caches.open(this.precacheStrategy.cacheName)).match(a)}createHandlerBoundToUrl(e){let t=this.getPrecacheKeyForUrl(e);if(!t)throw new l("non-precached-url",{url:e});return a=>(a.request=new Request(e),a.params={cacheKey:t,...a.params},this.precacheStrategy.handle(a))}handleRequest({request:e,event:t}){let a,s=new URL(e.url,location.href);if(!s.protocol.startsWith("http"))return;let r=s.origin===location.origin,{params:i,route:n}=this.findMatchingRoute({event:t,request:e,sameOrigin:r,url:s}),c=n?.handler,o=e.method;if(!c&&this._defaultHandlerMap.has(o)&&(c=this._defaultHandlerMap.get(o)),!c)return;try{a=c.handle({url:s,request:e,event:t,params:i})}catch(e){a=Promise.reject(e)}let l=n?.catchHandler;return a instanceof Promise&&(this._catchHandler||l)&&(a=a.catch(async a=>{if(l)try{return await l.handle({url:s,request:e,event:t,params:i})}catch(e){e instanceof Error&&(a=e)}if(this._catchHandler)return this._catchHandler.handle({url:s,request:e,event:t});throw a})),a}findMatchingRoute({url:e,sameOrigin:t,request:a,event:s}){for(let r of this._routes.get(a.method)||[]){let i,n=r.match({url:e,sameOrigin:t,request:a,event:s});if(n)return Array.isArray(i=n)&&0===i.length||n.constructor===Object&&0===Object.keys(n).length?i=void 0:"boolean"==typeof n&&(i=void 0),{route:r,params:i}}return{}}};let eR="/offline",eE=["pages","api-responses","next-static","static-media","fallback","offline-fallback"],eq=[{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/api/"),handler:new ee({cacheName:"api-responses",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:100,maxAgeSeconds:3600})]})},{matcher:({request:e})=>"navigate"===e.mode,handler:new ee({cacheName:"pages",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400}),{handlerDidError:async()=>await caches.match(eR)||Response.error()}]})},{matcher:({url:e,sameOrigin:t})=>t&&e.pathname.startsWith("/_next/static/"),handler:new ey({cacheName:"next-static",plugins:[new ep({maxEntries:200,maxAgeSeconds:2592e3})]})},{matcher:({url:e})=>e.pathname.startsWith("/images/")||e.pathname.startsWith("/icons/")||/\.(?:png|jpe?g|gif|svg|webp|ico|woff2?)$/.test(e.pathname),handler:new ey({cacheName:"static-media",plugins:[new ep({maxEntries:100,maxAgeSeconds:2592e3})]})},{matcher:()=>!0,handler:new ee({cacheName:"fallback",networkTimeoutSeconds:5,plugins:[new ep({maxEntries:50,maxAgeSeconds:86400})]})}];new ev({precacheEntries:[{'revision':null,'url':'/_next/static/chunks/1079-304a546a4a673437.js'},{'revision':null,'url':'/_next/static/chunks/1896-359ed2e68a8c4f1d.js'},{'revision':null,'url':'/_next/static/chunks/1958-56cbbb8dba15fd2f.js'},{'revision':null,'url':'/_next/static/chunks/2346-36e36720a5f6591a.js'},{'revision':null,'url':'/_next/static/chunks/3268-0a7190b483de059a.js'},{'revision':null,'url':'/_next/static/chunks/4bd1b696-e356ca5ba0218e27.js'},{'revision':null,'url':'/_next/static/chunks/52774a7f.f2ab7a9b4b8ba576.js'},{'revision':null,'url':'/_next/static/chunks/5838-7e0aa455e9f24259.js'},{'revision':null,'url':'/_next/static/chunks/7117-207a7246fa92b547.js'},{'revision':null,'url':'/_next/static/chunks/7363-209a1a709d8f324d.js'},{'revision':null,'url':'/_next/static/chunks/7602.2868ff821d53ee09.js'},{'revision':null,'url':'/_next/static/chunks/7918-9cfe56e3ee27ed27.js'},{'revision':null,'url':'/_next/static/chunks/8500-f62a38ff68ab7f42.js'},{'revision':null,'url':'/_next/static/chunks/983-3a3522324101948e.js'},{'revision':null,'url':'/_next/static/chunks/9da6db1e-9623f3245f088d02.js'},{'revision':null,'url':'/_next/static/chunks/app/_global-error/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/_not-found/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/about/page-b78d804f2759f7dc.js'},{'revision':null,'url':'/_next/static/chunks/app/account/page-9300f08c33aa8887.js'},{'revision':null,'url':'/_next/static/chunks/app/admin/page-b3b6af59ae9681bb.js'},{'revision':null,'url':'/_next/static/chunks/app/api/admin/stats/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/books/%5B...path%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/checkout/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/content/%5B...path%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/gamelines/%5Bsport%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/props/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/%5Bid%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/games/tonight/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hero-prop/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/hotlist/%5Bsport%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/intelligence/feed/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/accuracy/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/ledger/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/lines/%5B...path%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/mlb/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/nba/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/soccer/%5Bleague%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/odds/wnba/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/add-leg/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/calculate/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/parlay/grade/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/players/search/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/live/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/most-parlayed/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/props/top-graded/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/scan/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/schedule/%5Bsport%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/cascade/%5Bplayer%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/depth/%5Bteam%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/leaders/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/lineup/%5Bteam%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/parlays-graded/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/player/%5Bname%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/stats/public/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/streaks/%5Bsport%5D/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/profile/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/recent-scans/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/user/scans/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/waitlist/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/webhook/nexapay/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/api/welcome-email/route-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/auth/callback/page-e6df9771a50edb87.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/%5Bslug%5D/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/blog/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/compare/page-5e631b0c698c6f5f.js'},{'revision':null,'url':'/_next/static/chunks/app/dashboard/page-a0dbaaf9df3645f2.js'},{'revision':null,'url':'/_next/static/chunks/app/explore/page-8856c642532e2773.js'},{'revision':null,'url':'/_next/static/chunks/app/forgot-password/page-a251f52b4206e8d1.js'},{'revision':null,'url':'/_next/static/chunks/app/game/%5Bid%5D/page-ac9d9a479ea7ab89.js'},{'revision':null,'url':'/_next/static/chunks/app/help/page-03c6534f006d09da.js'},{'revision':null,'url':'/_next/static/chunks/app/intelligence/page-772d69c9ecfe1503.js'},{'revision':null,'url':'/_next/static/chunks/app/invite/page-e1e3f46af5f1e44b.js'},{'revision':null,'url':'/_next/static/chunks/app/layout-94ab8755a348e2ac.js'},{'revision':null,'url':'/_next/static/chunks/app/ledger/page-47459e4f664b707a.js'},{'revision':null,'url':'/_next/static/chunks/app/login/page-fb8804a2532d10b8.js'},{'revision':null,'url':'/_next/static/chunks/app/marketplace/page-cbada49ae96a7527.js'},{'revision':null,'url':'/_next/static/chunks/app/not-found-c8c8c338b54c2369.js'},{'revision':null,'url':'/_next/static/chunks/app/notifications/page-b78d804f2759f7dc.js'},{'revision':null,'url':'/_next/static/chunks/app/offline/page-a7a57ff039afc1af.js'},{'revision':null,'url':'/_next/static/chunks/app/page-a5e46597b4d1d6b1.js'},{'revision':null,'url':'/_next/static/chunks/app/player/%5Bname%5D/page-a9a1a22752478255.js'},{'revision':null,'url':'/_next/static/chunks/app/pricing/page-049fa136ce667e3e.js'},{'revision':null,'url':'/_next/static/chunks/app/privacy/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/profile/page-d607627acb1b73ab.js'},{'revision':null,'url':'/_next/static/chunks/app/report/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/responsible-gambling/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/scan/page-9cf453400fd9bd5b.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/page-25678f7d56db0a16.js'},{'revision':null,'url':'/_next/static/chunks/app/settings/security/page-697d02aa2d05cf7e.js'},{'revision':null,'url':'/_next/static/chunks/app/signup/page-b6917fd30dbecebe.js'},{'revision':null,'url':'/_next/static/chunks/app/soccer/page-5a64e650908111d7.js'},{'revision':null,'url':'/_next/static/chunks/app/terminal/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/terms/page-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/app/tracker/page-e23baec2b3735bb1.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/cancel/page-3702e818a6853705.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/desk/page-24eaa80743986159.js'},{'revision':null,'url':'/_next/static/chunks/app/upgrade/success/page-ea358cb8f714f1ea.js'},{'revision':null,'url':'/_next/static/chunks/app/verify/page-9df376f065215168.js'},{'revision':null,'url':'/_next/static/chunks/app/welcome/page-259277230533599f.js'},{'revision':null,'url':'/_next/static/chunks/framework-95da69ac6843d788.js'},{'revision':null,'url':'/_next/static/chunks/main-a354262253293123.js'},{'revision':null,'url':'/_next/static/chunks/main-app-4231d5f500f33972.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/app-error-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/forbidden-eebe9d0829c9ea07.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/global-error-756d787dba63aa4d.js'},{'revision':null,'url':'/_next/static/chunks/next/dist/client/components/builtin/unauthorized-eebe9d0829c9ea07.js'},{'revision':'846118c33b2c0e922d7b3a7676f81f6f','url':'/_next/static/chunks/polyfills-42372ed130431b0a.js'},{'revision':null,'url':'/_next/static/chunks/webpack-42b9616650231776.js'},{'revision':null,'url':'/_next/static/css/e3440a4469d87735.css'},{'revision':'cd34c7793d9776838308cfbe5d93922b','url':'/_next/static/media/011e180705008d6f-s.woff2'},{'revision':'324703f03c390d2e2a4f387de85fe63d','url':'/_next/static/media/0aa834ed78bf6d07-s.woff2'},{'revision':'9dda5cfc9a46f256d0e131bb535e46f8','url':'/_next/static/media/19cfc7226ec3afaa-s.woff2'},{'revision':'7de6d31e5076eb437418b6e01998ee12','url':'/_next/static/media/20535187d867b7b9-s.woff2'},{'revision':'4e2553027f1d60eff32898367dd4d541','url':'/_next/static/media/21350d82a1f187e9-s.woff2'},{'revision':'0d11e07b5aaafda6ae2efc53256932ec','url':'/_next/static/media/37786be940ec402b-s.p.woff2'},{'revision':'5de0bff80f9f432b5cf52c1c2f80fe20','url':'/_next/static/media/46e154b2fcbd6033-s.woff2'},{'revision':'6041b3192dc3e1a50141d5d418a24199','url':'/_next/static/media/5356a6a4f2c8c8d8-s.woff2'},{'revision':'f572f7b57d27ec7fd8373961f0b762b0','url':'/_next/static/media/58f386aa6b1a2a92-s.woff2'},{'revision':'e829cbe042e8b2b2e6b99c0eec9e230d','url':'/_next/static/media/656feb427634a431-s.woff2'},{'revision':'54f02056e07c55023315568c637e3a96','url':'/_next/static/media/67957d42bae0796d-s.woff2'},{'revision':'e220ad1849c282590e47db0ff88fc3f3','url':'/_next/static/media/704b853f32d191d5-s.woff2'},{'revision':'662cde3543f3375874e26c26845cec22','url':'/_next/static/media/73cb51aac9c97f90-s.woff2'},{'revision':'afac1fab355fe8db5c2dcaa7623dffb3','url':'/_next/static/media/7ba5fb2a8c88521c-s.woff2'},{'revision':'c94e6e6c23e789fcb0fc60d790c9d2c1','url':'/_next/static/media/886030b0b59bc5a7-s.woff2'},{'revision':'01ba6c2a184b8cba08b0d57167664d75','url':'/_next/static/media/8e9860b6e62d6359-s.woff2'},{'revision':'17a5b2a15bf5647b49cadb9a3bb02e1c','url':'/_next/static/media/92eeb95d069020cc-s.woff2'},{'revision':'4a4e74bed5809194e4bc6538eb1a1e30','url':'/_next/static/media/939c4f875ee75fbb-s.woff2'},{'revision':'6708425c446b05de1c2d471a0eaf4099','url':'/_next/static/media/98e207f02528a563-s.p.woff2'},{'revision':'13dface50f6dc0bff1015561c84119e6','url':'/_next/static/media/991629005c80bdf1-s.woff2'},{'revision':'216ee1a35b060106ed2852cdd8c026c7','url':'/_next/static/media/99dcf268bda04fe5-s.woff2'},{'revision':'9e494903d6b0ffec1a1e14d34427d44d','url':'/_next/static/media/ba9851c3c22cd980-s.woff2'},{'revision':'782150e6836b9b074d1a798807adcb18','url':'/_next/static/media/bb3ef058b751a6ad-s.p.woff2'},{'revision':'027a89e9ab733a145db70f09b8a18b42','url':'/_next/static/media/c5fe6dc8356a8c31-s.woff2'},{'revision':'75fa2e527eaae805bbf44e3378d1232c','url':'/_next/static/media/d26bbd13d6b70f89-s.woff2'},{'revision':'0ba762bde65aaa682fcc089f8c57288e','url':'/_next/static/media/d29838c109ef09b4-s.woff2'},{'revision':'814fe1615d4dbc7bbd92d26a6bda0905','url':'/_next/static/media/d3ebbfd689654d3a-s.p.woff2'},{'revision':'e71a597e376ed91862e822b5a5de8c04','url':'/_next/static/media/db96af6b531dc71f-s.p.woff2'},{'revision':'d54db44de5ccb18886ece2fda72bdfe0','url':'/_next/static/media/df0a9ae256c0569c-s.woff2'},{'revision':'9fb609fe01d739bb9df558e1fba2498e','url':'/_next/static/media/e40af3453d7c920a-s.woff2'},{'revision':'65850a373e258f1c897a2b3d75eb74de','url':'/_next/static/media/e4af272ccee01ff0-s.p.woff2'},{'revision':'de1d49f26130d43f91829088086625f4','url':'/_next/static/media/ef4d5661765d0e49-s.woff2'},{'revision':'0f8d347d49960d05c9430d83e49edeb7','url':'/_next/static/media/f911b923c6adde36-s.woff2'},{'revision':'4bc708c55b4aa5e00744888c6e3e39eb','url':'/_next/static/wiWR1WsBdedvPC0hW5N-t/_buildManifest.js'},{'revision':'b6652df95db52feb4daf4eca35380933','url':'/_next/static/wiWR1WsBdedvPC0hW5N-t/_ssgManifest.js'},{'revision':'d7bc0f63f9618b8b700d028d7d242de8','url':'/apple-touch-icon.png'},{'revision':'5616f81ec5f8a9f726a17fb7ac66a54a','url':'/favicon-16.png'},{'revision':'1fc285cfb0116a0523eb34c779a7d282','url':'/favicon-32.png'},{'revision':'583b034f36839a8af92841771eef38b6','url':'/favicon.ico'},{'revision':'b15795128b3691e5703d0ce14ce10827','url':'/favicon.png'},{'revision':'44e9d71551be13574ac2e10063d03aa9','url':'/favicon.svg'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icon-512.png'},{'revision':'252b8f8c4d0f64c7cd120f182a6c74ab','url':'/icons/icon-192.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-512.png'},{'revision':'3e3b53bbd210bd772b36b70165657e7c','url':'/icons/icon-maskable-512.png'},{'revision':'bf670b1fafa1b3127bd50abe86d723b9','url':'/images/player-silhouette.svg'},{'revision':'d692f690fbd1564f090131e02be5ba9c','url':'/manifest.json'},{'revision':'9f61cf51298661d5c57a782534216240','url':'/og-image.png'},{'revision':'7c759c20b35840eacf9344692cb51061','url':'/og-image.svg'},{'revision':'9327802333275f11e68c3f19f20c160d','url':'/widget.js'}],skipWaiting:!0,clientsClaim:!0,navigationPreload:!0,runtimeCaching:eq}).addEventListeners(),self.addEventListener("install",e=>{e.waitUntil(caches.open("offline-fallback").then(e=>e.add(eR)).catch(()=>{}))}),self.addEventListener("activate",e=>{e.waitUntil(caches.keys().then(e=>Promise.all(e.filter(e=>!eE.includes(e)&&!e.startsWith("serwist")).map(e=>(console.log("[SW] deleting stale cache:",e),caches.delete(e))))))}),self.addEventListener("push",e=>{let t;if(!e.data)return;try{t=e.data.json()}catch{t={title:"VYNDR",body:e.data.text()}}let{title:a="VYNDR",body:s="",icon:r="/icons/icon-192.png",url:i="/",tag:n="vyndr-notification"}=t;e.waitUntil(self.registration.showNotification(a,{body:s,icon:r,badge:"/icons/icon-192.png",tag:n,data:{url:i}}))}),self.addEventListener("notificationclick",e=>{e.notification.close();let t=e.notification.data?.url??"/";e.waitUntil(self.clients.matchAll({type:"window",includeUncontrolled:!0}).then(e=>{let a=e.find(e=>e.url.endsWith(t));return a?a.focus():self.clients.openWindow(t)}))})})(); \ No newline at end of file diff --git a/web/src/app/api/stats/cascade/[player]/route.ts b/web/src/app/api/stats/cascade/[player]/route.ts new file mode 100644 index 0000000..d07025a --- /dev/null +++ b/web/src/app/api/stats/cascade/[player]/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** Cascade proxy (Session 44) — forwards GET /api/stats/cascade/:player to Express. */ +export async function GET(req: NextRequest, ctx: { params: Promise<{ player: string }> }) { + const { player } = await ctx.params; + const qs = req.nextUrl.search; + try { + const upstream = await fetch(`${BACKEND_URL}/api/stats/cascade/${encodeURIComponent(player)}${qs}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ error: 'Cascade service is unreachable. Try again in a moment.' }, { status: 502 }); + } +} diff --git a/web/src/app/api/stats/depth/[team]/route.ts b/web/src/app/api/stats/depth/[team]/route.ts new file mode 100644 index 0000000..0d6d9f4 --- /dev/null +++ b/web/src/app/api/stats/depth/[team]/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** Depth chart proxy (Session 44) — forwards GET /api/stats/depth/:team to Express. */ +export async function GET(req: NextRequest, ctx: { params: Promise<{ team: string }> }) { + const { team } = await ctx.params; + const qs = req.nextUrl.search; + try { + const upstream = await fetch(`${BACKEND_URL}/api/stats/depth/${encodeURIComponent(team)}${qs}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ error: 'Depth chart service is unreachable. Try again in a moment.' }, { status: 502 }); + } +} diff --git a/web/src/app/api/stats/lineup/[team]/route.ts b/web/src/app/api/stats/lineup/[team]/route.ts new file mode 100644 index 0000000..77d704a --- /dev/null +++ b/web/src/app/api/stats/lineup/[team]/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** Lineup proxy (Session 44) — forwards GET /api/stats/lineup/:team to Express. */ +export async function GET(req: NextRequest, ctx: { params: Promise<{ team: string }> }) { + const { team } = await ctx.params; + const qs = req.nextUrl.search; + try { + const upstream = await fetch(`${BACKEND_URL}/api/stats/lineup/${encodeURIComponent(team)}${qs}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ error: 'Lineup service is unreachable. Try again in a moment.' }, { status: 502 }); + } +} diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 6ff7145..8919804 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -47,6 +47,18 @@ interface ScanResponse { tier: 'free' | 'analyst' | 'desk'; error?: string; upgrade?: { tier: string; price: number }; + // Session 43/44 — Player-Intelligence fields the engine attaches; the grade + // card's STAT CONTEXT + VYNDR INTELLIGENCE sections read these. + season_avg?: number; + last10_avg?: number; + vs_opp_avg?: number; + form?: number; + usage?: string; + matchup_grade?: string; + rest?: string; + archetype?: string; + archetype_blend?: { archetype: string; weight: number }[]; + prop_dna?: { reliable: string[]; volatile: string[] }; } const NBA_STATS = [ @@ -689,6 +701,18 @@ export default function ScanPage() { alt_lines: result.alt_lines, kill_conditions: result.kill_conditions, tier, + // Session 44 — forward the engine's intel fields so the grade + // card's STAT CONTEXT + VYNDR INTELLIGENCE sections populate. + season_avg: result.season_avg, + last10_avg: result.last10_avg, + vs_opp_avg: result.vs_opp_avg, + form: result.form, + usage: result.usage, + matchup_grade: result.matchup_grade, + rest: result.rest, + archetype: result.archetype, + archetype_blend: result.archetype_blend, + prop_dna: result.prop_dna, }) as GradeResultData} onAddToParlay={() => { addLeg({ diff --git a/web/src/components/Features.tsx b/web/src/components/Features.tsx index b46fc22..5a7d8c1 100644 --- a/web/src/components/Features.tsx +++ b/web/src/components/Features.tsx @@ -1,33 +1,33 @@ const FEATURES = [ { icon: '◆', - title: 'Multi-dimensional player archetypes', - body: 'Players aren\'t one thing. Our model scores every dimension — pitcher discipline, batter approach, NBA usage shape — and blends them per matchup.', + title: 'Player DNA archetypes', + body: 'Every player has a prop fingerprint. We classify it, show you which props are reliable vs volatile, and grade accordingly.', }, { icon: '↻', - title: 'Auto-calibrating engine', - body: 'Every resolved grade trains the next one. Point-biserial weight tuning, per-stat calibration, blind-spot detection. The model improves itself.', + title: 'Self-improving model', + body: 'Every resolved grade makes the next one sharper. The engine learns what works and corrects what doesn\'t.', }, { icon: '⚡', - title: 'Beat reporter intelligence', - body: 'Lineup intel from the people closest to the team — 30 minutes before tip. Trust-tiered, redistribution-aware, line-correlated.', + title: 'Lineup intel before tip-off', + body: 'Real-time lineup and injury data from trusted sources, giving you the edge before the books adjust.', }, { icon: '⊘', title: 'Kill conditions', - body: 'We don\'t just grade the prop. We tell you what kills it. Six hard checks per read: minutes, sample, fatigue, blowout risk, splits, line conflict.', + body: 'Six hard checks on every prop. If minutes, fatigue, blowout risk, or splits say no, we flag it — even on an A grade.', }, { icon: '∿', title: 'Parlay correlation math', - body: 'Phi-coefficient analysis catches the legs that secretly fight each other. The books love correlated unders. We surface them.', + body: 'We catch the legs that secretly fight each other. The books love correlated unders — we surface them before you tap.', }, { icon: '⌧', - title: 'ABS intelligence (MLB)', - body: 'The automated strike zone changes everything. Per-pitcher, per-batter discipline scoring. Zone 14 framing loss. Challenge math.', + title: 'Deep pitcher-batter matchups', + body: 'We score discipline, contact quality, and zone tendencies for every MLB matchup. Not just ERA.', }, { icon: '◯', diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index 630cb00..31571d1 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import GameCard, { SlateSport, GameLines, GameStreak } from '@/components/GameCard'; import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow'; +import { isRelevantGame } from '@/lib/slateAdapter'; import { useAuth } from '@/contexts/AuthContext'; // Session 23 — all-day intelligence layer. The stat filter is the // navigation system; streaks + hot lists layer ON TOP of the odds the @@ -507,9 +508,12 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps) // Filter pipeline — searchQuery applied to games + props. const filteredGames = useMemo(() => { - if (!searchQuery.trim()) return games; + // Session 44 — drop completed games >24h old so a 5-day-old FINAL never + // lingers on the dashboard. Upcoming + live always show. + const fresh = games.filter((g) => isRelevantGame(g)); + if (!searchQuery.trim()) return fresh; const q = searchQuery.toLowerCase(); - return games + return fresh .map((g) => { const homeMatch = g.homeTeam.toLowerCase().includes(q); const awayMatch = g.awayTeam.toLowerCase().includes(q); diff --git a/web/src/components/vyndr/ArchetypeBadge.tsx b/web/src/components/vyndr/ArchetypeBadge.tsx index 9f237f6..f70714e 100644 --- a/web/src/components/vyndr/ArchetypeBadge.tsx +++ b/web/src/components/vyndr/ArchetypeBadge.tsx @@ -1,7 +1,7 @@ import { badgeStyle, glyphSvg } from '@/lib/archetypes'; interface ArchetypeBadgeProps { - archetype: string; // archetype name, e.g. "POWER PULL" (case-insensitive) + archetype: string; // VYNDR archetype name, e.g. "BOMBER" (case-insensitive; legacy names resolve too) sport?: string; // nba/mlb/wnba/soccer — informational; styling is per-archetype variant?: 'full' | 'ghost' | 'tint'; size?: 'sm' | 'md'; diff --git a/web/src/lib/archetypes.js b/web/src/lib/archetypes.js index b0be437..ea74753 100644 --- a/web/src/lib/archetypes.js +++ b/web/src/lib/archetypes.js @@ -33,60 +33,71 @@ const GLYPHS = { postup: '', }; -/* name → { color, glyph, desc } — the design's MAP. */ +/* name → { color, glyph, desc, legacy } — VYNDR Originals (Session 44). + Keys/colors/glyphs MUST match src/services/archetypeService.js ARCHETYPES. + `legacy` is the old descriptive label, kept for reference (never displayed). */ const ARCHETYPE_MAP = { // NBA - 'VOLUME SCORER': { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle' }, - 'FLOOR GENERAL': { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node' }, - 'TWO-WAY ANCHOR': { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield' }, - 'STRETCH BIG': { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target' }, - 'USAGE SPONGE': { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow' }, - 'COMBO GUARD': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin' }, - 'ROLE GLUE': { c: '#9499A8', d: 'Low-usage specialist', g: 'chain' }, - 'TRANSITION ENGINE': { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons' }, - 'POST SCORER': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup' }, - 'DEFENSIVE SPECIALIST': { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck' }, - 'POINT FORWARD': { c: '#38BDF8', d: 'Oversized primary creator', g: 'half' }, - SLASHER: { c: '#FB923C', d: 'Rim-attacking, foul-drawing driver', g: 'slash' }, - 'RIM RUNNER': { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc' }, - '3-AND-D': { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair' }, - 'SIXTH MAN': { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt' }, + TORCH: { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle', legacy: 'VOLUME SCORER' }, + CONDUCTOR: { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node', legacy: 'FLOOR GENERAL' }, + FORTRESS: { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield', legacy: 'TWO-WAY ANCHOR' }, + ARTILLERY: { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target', legacy: 'STRETCH BIG' }, + SURGE: { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow', legacy: 'USAGE SPONGE' }, + 'DUAL THREAT': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin', legacy: 'COMBO GUARD' }, + CONNECTOR: { c: '#9499A8', d: 'Low-usage specialist', g: 'chain', legacy: 'ROLE GLUE' }, + FASTBREAK: { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons', legacy: 'TRANSITION ENGINE' }, + 'PAINT BOSS': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup', legacy: 'POST SCORER' }, + LOCKDOWN: { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck', legacy: 'DEFENSIVE SPECIALIST' }, + SWITCHBOARD: { c: '#38BDF8', d: 'Oversized primary creator', g: 'half', legacy: 'POINT FORWARD' }, + ARCHITECT: { c: '#FB923C', d: 'Self-created shot maker, rim-attacking', g: 'slash', legacy: 'SLASHER' }, + PISTON: { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc', legacy: 'RIM RUNNER' }, + SENTINEL: { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair', legacy: '3-AND-D' }, + IGNITER: { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt', legacy: 'SIXTH MAN' }, // WNBA-unique - 'POST FACILITATOR': { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node' }, - 'TWO-WAY WING': { c: '#A78BFA', d: 'Two-way perimeter wing', g: 'shieldCheck' }, - 'STRETCH FORWARD': { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target' }, - 'SLASHING GUARD': { c: '#FB923C', d: 'Downhill driving guard', g: 'slash' }, - 'INTERIOR ANCHOR': { c: '#6366F1', d: 'Paint defender and rebounder', g: 'shield' }, + DISTRIBUTOR: { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node', legacy: 'POST FACILITATOR' }, + SHIELD: { c: '#A78BFA', d: 'Two-way perimeter forward', g: 'shieldCheck', legacy: 'TWO-WAY WING' }, + RANGE: { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target', legacy: 'STRETCH FORWARD' }, + SPARK: { c: '#FB923C', d: 'Downhill scoring guard', g: 'slash', legacy: 'SLASHING GUARD' }, + ANCHOR: { c: '#6366F1', d: 'Dominant paint defender and rebounder', g: 'shield', legacy: 'INTERIOR ANCHOR' }, // MLB - 'POWER PULL': { c: '#FF5C5C', d: 'HR-dependent, high strikeout power', g: 'batball' }, - CONTACT: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair' }, - 'RUN PRODUCER': { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond' }, - ACE: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star' }, - 'BULLPEN ARM': { c: '#FFB347', d: 'Short outings, high leverage', g: 'bolt' }, - 'SPEED THREAT': { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons' }, - 'TWO-WAY PLAYER': { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half' }, - 'UTILITY PLAYER': { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus' }, - 'INNINGS EATER': { c: '#818CF8', d: 'Durable, deep-start workhorse', g: 'clock' }, - 'POWER SLUGGER': { c: '#FF6B4A', d: 'All-fields power producer', g: 'triangle' }, - 'TABLE SETTER': { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine' }, - 'GAP HITTER': { c: '#34D399', d: 'Doubles and extra-base gaps', g: 'uparrow' }, - CLOSER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock' }, - SWINGMAN: { c: '#FBBF24', d: 'Spot starter and long relief', g: 'swap' }, - 'DEFENSIVE WIZARD': { c: '#6366F1', d: 'Glove-first defensive value', g: 'shieldCheck' }, + BOMBER: { c: '#FF6B4A', d: 'Middle-of-the-order power producer', g: 'triangle', legacy: 'POWER SLUGGER' }, + BRUSH: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair', legacy: 'CONTACT' }, + DRIVER: { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond', legacy: 'RUN PRODUCER' }, + ALPHA: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star', legacy: 'ACE' }, + WHIFF: { c: '#FFB347', d: 'Bat-missing arm, high K with traffic', g: 'bolt', legacy: 'STRIKEOUT ARTIST' }, + GHOST: { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons', legacy: 'SPEED THREAT' }, + HYBRID: { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half', legacy: 'TWO-WAY PLAYER' }, + FLEX: { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus', legacy: 'UTILITY PLAYER' }, + WORKHORSE: { c: '#818CF8', d: 'Durable, deep-start arm', g: 'clock', legacy: 'INNINGS EATER' }, + CATALYST: { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine', legacy: 'TABLE SETTER' }, + MIRROR: { c: '#34D399', d: 'Gap-to-gap line-drive bat', g: 'uparrow', legacy: 'GAP HITTER' }, + HAMMER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock', legacy: 'CLOSER' }, + SINKER: { c: '#FBBF24', d: 'Groundball spot-starter / long relief', g: 'swap', legacy: 'SWINGMAN' }, + BRIDGE: { c: '#6366F1', d: 'Setup / high-leverage middle relief', g: 'shieldCheck', legacy: 'BULLPEN ARM' }, + SWITCH: { c: '#FF5C5C', d: 'Platoon-leveraged, glove-first bat', g: 'batball', legacy: 'DEFENSIVE WIZARD' }, // Soccer - POACHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair' }, - CREATOR: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node' }, - 'TARGET MAN': { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle' }, - 'BOX-TO-BOX': { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons' }, - 'WING WIZARD': { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash' }, - 'SWEEPER KEEPER': { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield' }, + FINISHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair', legacy: 'POACHER' }, + MAESTRO: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node', legacy: 'CREATOR' }, + TOWER: { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle', legacy: 'TARGET MAN' }, + MOTOR: { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons', legacy: 'BOX-TO-BOX' }, + BLADE: { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash', legacy: 'WING WIZARD' }, + WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' }, }; const FALLBACK = { c: '#9499A8', d: '', g: '' }; +// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to +// its VYNDR Original (BOMBER) — defends any stale cached data post-rename. +const LEGACY_INDEX = {}; +for (const [k, v] of Object.entries(ARCHETYPE_MAP)) { + if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k; +} + function archetypeInfo(name) { const key = (name == null ? '' : String(name)).toUpperCase(); - return ARCHETYPE_MAP[key] || FALLBACK; + if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key]; + if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]]; + return FALLBACK; } function archetypeColor(name) { @@ -115,8 +126,11 @@ function badgeStyle(name, variant = 'tint', size = 'sm') { } else { textColor = info.c; bg = info.c + '1F'; borderColor = info.c + '52'; glyphColor = info.c; } + // Display the canonical VYNDR name even if a legacy name was passed. + const upper = (name == null ? '' : String(name)).toUpperCase(); + const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper; return { - name: (name == null ? '' : String(name)).toUpperCase(), + name: canonical, desc: info.d, glyph: info.g, color: info.c, diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index 19d8544..65d8dbd 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -175,6 +175,23 @@ function mapPitchers(game) { }; } +/** + * Should this game still show on the slate (Session 44)? Upcoming + live games + * always show; a COMPLETED game is dropped once it's more than 24h old, so a + * 5-day-old FINAL never lingers on the dashboard. Unknown/missing date → keep + * (degrade open). `now` is injectable for tests. + */ +function isRelevantGame(game, now = Date.now()) { + if (!game) return false; + const state = String(game.state || game.status || '').toLowerCase(); + const isFinal = state === 'final' || state === 'post' || state === 'closed' || state === 'complete'; + if (!isFinal) return true; + const raw = game.date || game.gameTime || game.commence_time || game.startTime; + const t = raw ? new Date(raw).getTime() : NaN; + if (Number.isNaN(t)) return true; // no parseable date → don't hide + return (now - t) / 3_600_000 < 24; +} + module.exports = { parseAmericanOdds, detectBestLines, @@ -183,4 +200,5 @@ module.exports = { formatGameTime, groupPropsByPlayer, mapPitchers, + isRelevantGame, };