# VYNDR — Build State ## Last Updated 2026-06-18 ## Current Phase SHIP BUILD v52.0 — Push-to-Book "Coming Soon" teaser + infrastructure verification (snapshot status probe, scheduler logs, delta pipeline confirmed). ## Session 52 (2026-06-19) — SHIPPED ✅ TEASER + INFRA VERIFICATION Backend 2234 → **2239 tests** (+5), 192 suites. Web build clean (exit 0). ### Phase 1 — Push-to-Book teaser (feature NOT live; teaser only) - `StatStrip`: a `BookItTeaser` ("BOOK IT ⟶", hover tooltip "Push-to-Book coming soon — connect your sportsbook") after the parlay "+" on every graded prop. - `GradeResultCard`: a "PUSH-TO-BOOK · COMING SOON" footer section. ### Phase 2 — infrastructure verification - `snapshotScheduler` now logs BOTH states: armed (`[snapshotScheduler] armed — SNAPSHOT_CRON=1, hours=…`) and disarmed (so container logs disambiguate off-vs-crashed). Test asserts the armed log. - NEW `GET /api/internal/snapshot/status` (internal-key gated): `{ cron_armed, cron_hours_utc, last_snapshot:{sport:{updated_at,gradeCount,deltaCount}}, redis_keys:{…:bool}, ticker_count }`. The single probe to verify the pipeline post-deploy. - REDIS PERSISTENCE FINDING: the app uses `REDIS_URL` (ioredis) — AOF/RDB persistence is a server-side (Coolify Redis) config the app can't set/verify. If snapshot keys vanish on restart, enable persistence on the Redis instance. ### Phase 3 — delta pipeline (verified sound; no fix needed) `runSnapshot` already reads `:latest` as prev → `computeLineDeltas(enriched, prev.grades)` → writes old→`:previous`, new→`:latest`. So deltas populate on the 2nd+ run. Added an opt-in debug log (`SNAPSHOT_DEBUG=1`) + a trace test asserting `:previous` is preserved verbatim and the delta math is correct. ## Session 51 (2026-06-19) — SHIPPED ✅ TEAM HUB ## Session 51 (2026-06-19) — SHIPPED ✅ TEAM HUB Backend 2215 → **2234 tests** (+19), 190 suites. Web build clean (exit 0). New routes: `/team/[abbr]`, `/api/team/[abbr]`. ### Phase 1 — team data API - `mlbStatsAdapter` gained `getTeams()` / `resolveTeam(abbr)` (statsapi `/teams?sportId=1`, cached 24h, abbr→id) + `getTeamRoster(teamId)` (active roster, cached 6h). - `src/services/teamService.js getTeamHub(sport, abbr)` assembles the hub: resolve team → roster → per-player season stats (bounded concurrency 8, reuses playerIntelService mappers) → archetype (from the snapshot grade, else classify) → tonight's graded props (from `grades:{sport}`). Whole result cached 15 min. MLB = real; NBA/WNBA = a snapshot-built partial roster (graceful note). - `GET /api/team/:abbr` (public, 404 on unknown MLB team) + Next proxy. ### Phase 2 — Team Hub page `team/[abbr]/page.tsx` (server, `generateMetadata`) + `TeamHub.tsx` (client): team header + sport badge, sort (archetype / graded / A–Z), archetype filter chips, roster rows (archetype badge + player link + position + horizontal stats + graded props with grade badges + parlay "+"), "No active props" greyed state, loading/error states. ### Phase 3 — game card team links + back nav `vyndr/GameCard` team abbreviations are now `TeamLink`s → `/team/:abbr?sport=` (green hover, stops propagation from the open-game handler). Team Hub has "← Back to Slate". ## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB ## Session 50 (2026-06-19) — SHIPPED ✅ PARLAY LAB Backend 2185 → **2215 tests** (+30), 187 suites. Web build clean (exit 0). ### Phase 1 — correlation-score model (parlayService.js, ADDED to S28 funcs) Numeric, GAME-AWARE model: `correlationScore(l1,l2)` → 0.7 same-player/game / 0.4 same-team/game / 0.2 same-game-diff-team / 0.0 diff-game. `combinedGrade` = leg-grade avg penalized by `avgCorrelation * 0.5`. `estimatedPayout` = product of per-grade fair odds × (1 − avgCorrelation) discount. `correlationWarning` → "⚠ N legs from {TEAM}…". `gradeParlay` bundles it all. ### Phase 2 — POST /api/parlay/grade Returns `{ combined, correlation, payout, legs }` (2–6 legs, public/stateless). The Next `/api/parlay/grade` proxy was forwarding to the wrong upstream (`/api/scan/parlay`) — fixed to the new route. ### Phase 3 — ParlayContext extended `ParlayLeg` gained team/game/archetype; MAX_LEGS now 6 with a tier-aware `maxLegs` (set by the panel). Auto-grades the slip (debounced) via the endpoint when legs ≥ 2 → live `combined`/`correlation`/`payout`. + `hasLeg`/`legKey`/`atCap`. ### Phase 4 — "+" buttons `StatStrip` renders a "+"/"✓" per graded prop (toggles add/remove); `vyndr/GameCard` wires it via `useParlay` (builds a leg with team + game id). `GradeResultCard`'s existing "Add to Parlay" feeds the same context from the scan page. ### Phase 5 — ParlayPanel (replaces legacy ParlayTray in the layout) Bottom slide-up: legs (archetype + grade + remove), correlation warning, combined grade, est. payout, CLEAR ALL + a floating leg-count badge (bottom-right) when closed. Tier-gated: free 2 legs (payout blurred → Desk upgrade), Analyst 4, Desk 6. ## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW ## Session 49 (2026-06-19) — SHIPPED ✅ ONBOARDING FLOW Backend 2156 → **2185 tests** (+29), 184 suites. Web build clean (exit 0). New routes: `/onboarding`, `/api/preferences`. ### Phase 1 — name micro-fixes (closes the normalization arc) `playerName.js` (both copies): collapse adjacent single-letter words ("J C Escarra" → "JC Escarra", display + key) + added `mickey: 'michael'`. ### Phases 2–5 — onboarding, end-to-end - **Storage:** Supabase `user_metadata.preferences` — NO migration. Shape: `{ sports[], books[], weekly_limit, onboarding_complete }`. - **API:** `src/routes/preferences.js` GET/POST (requireAuth; service-client admin getUserById/updateUserById; POST is a PARTIAL merge + sanitized) + `/api/preferences` Next proxy (forwards the bearer). - **Page:** `web/src/app/onboarding/page.tsx` — 3 self-contained steps (sports [≥1 required] → books [skip] → weekly bankroll [presets + custom + skip]) → "SIGNAL ACTIVE / You're locked in" → POST `onboarding_complete:true` → 2s → `/dashboard`. Redirects to /login when unauthenticated. - **Redirect:** the dashboard fetches `/api/preferences` (fresh — avoids stale session metadata); if `onboarding_complete !== true` AND the user is NEW (`created_at >= 2026-06-19 cutoff`), → `/onboarding`. Never fires while auth is loading (would bounce unauthenticated users). Existing users are exempt. - **Personalization:** the Slate defaults its tab to `prefs.sports[0]`; preferred books are highlighted (green glow) in each game card's lines grid (`isPreferredBook` in `lib/books.js`, threaded dashboard → Slate → GameCard). - **Settings:** a PREFERENCES section loads (GET) + edits + saves (POST) sports, books, and weekly limit. ## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER ## Session 48 (2026-06-19) — SHIPPED ✅ NORMALIZATION AT EVERY LAYER The normalizer functions were correct (S47) but raw names still flowed through paths that skipped them. Traced top-down, fixed each. Backend 2149 → **2156 tests** (+7), 181 suites. Web build clean (exit 0). ### The trace (3 leaking paths) 1. **Snapshot grades** stored RAW player names + no dedup → Explore/leaders/ profile/GameCard-overlay all saw variants. 2. **Game-card strips** merged the player but pushed EVERY variant prop → "Ks 5.5 AND Ks 3.5" duplicate rows. 3. **Scan player grid** (`tonightsPlayers`) grouped by raw `p.player` → variant tiles. ### Fixes - **2a (source chokepoint):** `snapshotService.runSnapshot` now normalizes each grade's player to the de-dotted display AND dedupes to ONE grade per normalized player+stat (highest confidence) BEFORE writing `grades:{sport}` + `snapshot:{sport}:latest`. Every consumer now gets clean, merged names for free. - **2b:** `buildPlayerStripsFromProps` dedupes a player's props by stat (prefers the graded over an awaiting row) → one row per stat. - **2c:** scan `tonightsPlayers` groups by `nameKey`, displays `normalizeName().display`. - **3 (profile usage/rest):** the "+0%"/"—" was the PLAYER PROFILE's `buildIntel` (separate from the grade card's `buildIntelFields`, which already works — the full feature merge carries ab_per_game/rest_days, NOT filtered by FEATURE_NAMES which is meta-only). `resolvePlayerStats` (MLB) now attaches real `usage` (AB/G) + `rest` (B2B/Xd) and `buildIntel` renders them; REST default is now "—" not "+0%". ## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH ## Session 47 (2026-06-19) — SHIPPED ✅ NAME NORM + INTEL + TICKER POLISH Backend 2122 → **2149 tests** (+27), 179 suites. Web build clean (exit 0). ### Phase 1 — complete name normalization `playerName.js` (both copies) gained: parenthetical-tag strip ("Jose Fermin (STL)" → "Jose Fermin"), a ~50-entry NICKNAMES table resolved in `nameKey` ("Matt"↔ "Matthew", "Mike"↔"Michael"), and the existing accent-fold now verified for Iván/Ivan, José/Jose. The slate strip now DISPLAYS the normalized de-dotted name (`buildPlayerStripsFromProps` uses `normalizeName().display`) — "A.J. Ewing" shows as "AJ Ewing", not the raw PropLine string. ### Phase 2 — complete VYNDR INTELLIGENCE for MLB The audit showed only Form. `mlbGameLogFeatures` now also derives `rest_days` (days off between the two most recent game-log dates; 0 = B2B, matching the NBA convention) and `ab_per_game` (the MLB "usage" equivalent). `buildIntelFields` renders `usage` as "X AB/G", `rest` as "B2B"/"Xd rest", and a matchup grade from `bvp_advantage` when no opp rank. So MLB grade cards now show Form + Usage + Rest (+ Matchup when available). ### Phase 3 — ticker SCAN dedup `pushTickerItems` keeps only the LATEST SCAN per sport (drops prior SCAN events for any sport with a fresh scan; parses the sport from a `sport` field or the text prefix for legacy items). MOVE/GRADE events preserved; cap stays 50. ### Phase 4 — archetype threshold BOMBER's HR thresholds prorated for mid-season (`hr>=15` strong / `hr>=10` moderate, was `>=20`/`>=15`), so June sluggers (Schwarber/Harper ~17-18 HR) classify as BOMBER instead of FLEX/DRIVER. `computeLineDeltas` re-verified structurally sound (deltas populate on the 2nd+ snapshot). ## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES ## Session 46 (2026-06-18) — SHIPPED ✅ P1 FIXES Backend 2100 → **2122 tests** (+22), 176 suites. Web build clean (exit 0). ### Phase 1 — grade card intel (ROOT CAUSE) The STAT CONTEXT + VYNDR INTELLIGENCE sections were empty for MLB because `gameLogService.getGameLogs` is NBA/WNBA-only (offline Python service) — MLB props NEVER got `l5_avg`/`l20_avg`, so `buildIntelFields` always returned `{}`. FIX: `featureCache.gameLogFeatures` now has an MLB branch that derives l5/l10/l20 averages from `mlbStatsAdapter.getPlayerStats` (free statsapi.mlb.com) via the pure `mlbGameLogFeatures` + an MLB stat_type→game-log-field map. `buildIntelFields(features, opts)` also gained `playerStats`/`projection` fallbacks so partial intel renders (Sessions 43/44 had the wiring; the engine just never produced the values for MLB). ### Phase 2 — player name normalization `src/utils/playerName.js` + `web/src/lib/playerName.js` (identical; cross-checked by a test): `normalizeName(raw)` → `{ display, key }`. Strips periods, de-dots suffixes, accent-folds the key. Applied in `snapshotService` (grouping/deltas), `slateAdapter` (grade index + `buildPlayerStripsFromProps` merges variants, displays the longest), and `playerIntelService.sanitizePlayerName`/`normName`. "A.J. Ewing"/"AJ Ewing" and "Jazz Chisholm"/"Jazz Chisholm Jr." now merge. ### Phase 3 — MLB starting pitchers The ESPN schedule lacks probable pitchers. NEW `GET /api/schedule/:sport/pitchers` (MLB) → `probablePitchers` service wrapping `mlbStatsAdapter.getScheduleWithPitchers` + best-effort season ERA. The Slate fetches it, builds a team→pitcher map (`slateAdapter.buildPitcherMap`/`pitchersForGameTeams`, matched by full name + mascot), and attaches `pitchers` to MLB GameCardData. + Next proxy. ### Phase 4 — verify `computeLineDeltas` confirmed structurally sound (S45 tests); `deltas: 0` in the audit was just the first snapshot (no previous to diff). BACKEND_HANDOFF.md updated. ## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE ## Session 45 (2026-06-18) — SHIPPED ✅ SNAPSHOT PIPELINE The on-demand grade model is retired. Backend 2061 → **2100 tests** (+39), 173 suites. Web build clean (exit 0). ### Phase 1 — snapshotService (orchestration of existing services) `src/services/snapshotService.js` `runSnapshot(sport)`: getOdds (PropLine rotation) → gradeAndCacheSlate (captured via injected cacheSet) → classify each player's archetype (resolvePlayerStats + archetypeService, pure math) → attach `gradedAt {line, odds, timestamp}` (LOCKED) → compute line deltas vs the previous snapshot → write `snapshot:{sport}:latest|previous` + `grades:{sport}` → emit ticker events. `runAllSnapshots()` loops mlb/nba/wnba/soccer. Everything injectable → fully unit-tested with zero network. Deltas: `toward` = market confirming our side, `away` = opposing; noise filtered <0.5. ### Phase 2 — internal API + cron + ticker API - `POST /api/internal/snapshot/:sport` + `/snapshot/all` (existing `requireInternalAuth`; `/all` registered first so it isn't captured as a sport). - `GET /api/ticker` (public, cache-only, merges `TICKER_MANUAL` pins) + Next proxy. - In-process cron `src/snapshotScheduler.js` (gated `SNAPSHOT_CRON=1`, UTC hours 14,19,22,1,3 = 10AM/3PM/6PM/9PM/11PM ET), armed in server.js. No new dep — 1-min unref'd interval, fires once per slot. Prefer external n8n cron hitting the internal endpoint for multi-replica deploys. ### Phase 3 — GameCard swap (THE product shift) The live Slate now renders `vyndr/GameCard` (legacy kept for TYPES only). Reads `GET /api/snapshot/:sport` (cache-only, public; falls back to `grades:{sport}`), overlays the locked grades onto each game's odds-derived props via `slateAdapter.buildPlayerStripsFromProps` → player name ONCE + archetype badge + locked grade + "Graded Xh ago at -115 · Current 2.5 · ▲ TOWARD +1.0" line delta. Ungraded props → "Awaiting next scan" (NO Read button). The on-demand onGrade flow was deleted. ### Phase 4 — live ticker `vyndr/Ticker` now polls `/api/ticker` every 30s (graceful: keeps the passed hardcoded items as initial/fallback, never blanks). Tag colors: A+/A/SCAN green, MOVE/CASCADE amber, ALERT white. ### Phase 5 — NBA/WNBA ESPN fallback `espnStatsAdapter.getSeasonAverages(name, sport)` — free public ESPN stats, DEFENSIVE (`parseAthleteStats` returns null on any unrecognized shape → caller degrades to found:false; never a wrong-but-confident number). Wired as the NBA/WNBA fallback in `resolvePlayerStats` after the offline Python service. Note: the live ESPN athlete-stats shape may need tuning against production — the parser is tolerant and tested against a representative payload. ### Env vars (set in Coolify) - `PROPLINE_API_KEY_1/2/3` — PropLine props rotation (snapshot odds source). - `VYNDR_INTERNAL_KEY` — the internal-auth secret (header `x-internal-key`) for the snapshot trigger. (This is the existing key, NOT a new INTERNAL_AUTH_TOKEN.) - `SNAPSHOT_CRON=1` — arm the in-process scheduler (omit if using n8n). - `TICKER_MANUAL` — JSON array of editorial pins, e.g. `[{"tag":"ALERT","text":"VYNDR 2.0 is live."}]`. ## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE ## 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 Post-S42 Chrome audit: architecture renders but no real data flowed (every player `found:false`, leaders empty, grade intel hidden) + a P0 dropdown bug. Backend 2011 → **2045 tests** (+34), 163 suites. Web build clean (exit 0). ### Phase 1 — P0 bug fixes - **Avatar/More dropdown z-index** — the `