Files
vyndr/CLAUDE.md
T
builtbykev c8fc9f577e Session 46: Grade card intel + name normalization + pitchers (2122 tests)
Three focused P1 fixes on the Session-45 snapshot model.

- Grade card intel ROOT CAUSE: gameLogService is NBA/WNBA-only (offline Python),
  so MLB props never got l5_avg/l20_avg and buildIntelFields returned {}. Wired
  MLB game logs into featureCache.gameLogFeatures via mlbStatsAdapter.getPlayerStats
  (pure mlbGameLogFeatures + MLB stat_type->field map). buildIntelFields gained
  playerStats/projection fallbacks for partial intel.
- Player name normalization: src/utils/playerName.js (+ web/src/lib copy):
  normalizeName -> {display,key}. Strips periods, de-dots suffix, accent-folds
  the key. Applied in snapshotService grouping, slateAdapter grade index +
  player-strip merge (variants collapse, longest name shown), and
  playerIntelService. "A.J. Ewing"/"AJ Ewing" + "Jazz Chisholm"/"Jr." now merge.
- MLB starting pitchers: new GET /api/schedule/:sport/pitchers (probablePitchers
  service wrapping mlbStatsAdapter.getScheduleWithPitchers + best-effort ERA).
  Slate fetches it, builds a team->pitcher map (full name + mascot match),
  attaches pitchers to MLB GameCardData. + Next proxy.

Backend 2100 -> 2122 tests (+22), 176 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:56:26 -04:00

30 KiB
Executable File
Raw Blame History

VYNDR — Claude Code Project Context

What This Is

Sports betting intelligence SaaS. Real software product. Three tiers: Free (5 scans), Analyst ($19.99 / $14.99 founder), Desk ($49.99 / $34.99 founder).

Tech Stack

  • Backend: Node.js / Express
  • Database: Supabase (PostgreSQL)
  • Frontend: React Native (built in Cursor)
  • Data: The Odds API ($30/mo), nba_api (free, Python wrapper)
  • Caching: Redis — 15min for odds, 24hr for season averages, 1hr for recent games
  • Payments: Stripe

Critical Rules — Non-Negotiable

1. NO CODE WITHOUT A SPEC

Every feature requires a spec file in specs/ before any code is written. Spec must include: endpoints, data shapes, acceptance criteria, test plan. Get approval before building.

2. WSL2 HEREDOC RULE

WSL2 corrupts heredoc for files over 10 lines. ALWAYS use Python file-writing: python3 with triple-quoted strings. This applies to every file creation operation.

3. 5 QUALITY GATES (all must pass before any feature is marked complete)

  1. Unit tests pass
  2. Integration tests pass
  3. Acceptance criteria met (from spec)
  4. PR description written
  5. CLAUDE.md updated if anything new learned

4. BUILD-STATE.md

Update after every session. What shipped, what's next, any blockers.

5. BLOCKERS.md

If you hit something you cannot resolve: log it. Don't guess. Don't skip.

Folder Structure

vyndr/
├── src/
│   ├── routes/        # Express route handlers
│   ├── models/        # Supabase data models
│   ├── services/      # Business logic (prop analysis, odds normalization)
│   ├── middleware/     # Auth, rate limiting, scan counting
│   └── utils/         # Helpers, formatters, validators
├── tests/             # Unit + integration tests
├── docs/              # API docs, architecture notes
├── specs/             # Feature specs (write BEFORE code)
├── build-briefs/      # Session summaries
├── CLAUDE.md          # This file
├── ROADMAP.md         # Feature roadmap with phases
├── BUILD-STATE.md     # Current build status
├── BLOCKERS.md        # Unresolved blockers
└── DECISIONS.md       # Architecture decisions log

All-Day Intelligence Layer (Session 23)

Free/cheap content that keeps the platform alive when odds-api props are empty. NONE of these spend odds-api credits:

  • /api/schedule/:sport — cache-aside ESPN scoreboard (scheduleService), self-heals on cache miss. Per-game hasOdds/hasGameLines flags peek at other caches without fetching.
  • /api/gamelines/:sport — Tank01 book-by-book lines (RAPID_API_KEY quota).
  • /api/streaks/:sport + /api/hotlist/:sport — PURE engines (streaksService, hotListService) computed from cached game logs. NO API calls. Logs loaded by rosterLogs.js (prefetch blob, else Redis SCAN over gamelogs:{sport}:{player}:{count}). Empty roster = valid empty state.
  • ?stat= filters narrow streaks/hotlist; categories in config/statFilters.js (mirror web/src/config/statFilters.ts). Discovery: /api/stats/filters/:sport.
  • Dead providers: set status: 'dead' in config/providers.js to drop a provider from fallback chains + configured list (ParlayAPI host is dead).

Provider Strategy (Session 30)

Player props now have abundance, not rationing.

  • Player props — PRIMARY: PropLine (proplineAdapter, 3 keys PROPLINE_API_KEY_1/2/3, 3,000 req/day FREE, rotates per-key; registry propline priority 1). BACKUP: The Odds API (ODDS_API_KEY, 500/month, priority 2, conserve). getOdds() tries PropLine first when keys present, falls back to odds-api; the response + cache carry a provider field. PropLine is The-Odds-API-compatible → reuses utils/oddsNormalizer. MLB market keys (batter_hits, pitcher_strikeouts, …) were added to MARKET_MAP — without them MLB props normalize to zero.
  • MLB statsmlbStatsAdapter → statsapi.mlb.com. FREE, no auth, unlimited. Game logs, season averages, BvP, probable pitchers. Does NOT use the gateway (no quota). Registry mlb-stats (noAuth: true).
  • Game enrichmentscheduleService.getGameSummary(sport, eventId) → ESPN summary (injuries, ESPN Bet odds, ATS, leaders, box score). Free.
  • Game-level odds — Tank01 (unchanged). Tank01 PLAYER PROPS = empty, do not wire.

Grades Content Pipeline (Session 32)

Closes the content pipeline: contentTemplateService reads a grades:{sport} cache "when present" but nothing wrote it, so slate/POTD never reached dataLevel: 'full'.

  • WritergradeSlateService.gradeAndCacheSlate(sport, props, opts) dedupes props to unique player+stat+line (cap 25, concurrency 5), grades BOTH sides via analyzeViaEngine1 (engine1 is direction-aware — keeps the higher-confidence side), sorts by confidence desc, writes grades:{sport} = { grades, updated_at, source } (TTL 2h). The legacy grade shape already matches normalizeGrade — no remap needed.
  • Trigger — fire-and-forget inside oddsService.recordDownstream (runs on a fresh odds fetch / cache MISS, NOT on cache hits). Does NOT hold the odds HTTP response. Gated by shouldGradeSlate(): ON by default, OFF when NODE_ENV==='test' (its feature-compute fan-out would pollute call-count assertions), override with GRADE_SLATE_ON_FETCH=1/0. 0 is the operator kill-switch if the per-prop feature-compute cost needs shedding.

NFL/NHL Props (Session 32)

NFL + NHL wired end-to-end. the-odds-api keys are americanfootball_nfl and icehockey_nhl (full-name prefix like basketball_nba) — NOT football_nfl/hockey_nhl (those are PropLine's keys in proplineAdapter). oddsService.SPORT_KEYS + SPORT_MARKETS carry both; proplineAdapter.MARKETS filled. NHL keys (player_shots_on_goal, goalie_saves) added to MARKET_MAP so NHL props don't silently normalize to zero in-season.

Public Route Rate Limiting (Session 32)

middleware/rateLimit (createRateLimit, in-memory per-IP, independent bucket per call site) now mounts via router.use at the top of every public cached router: odds + parlay = 30/min, schedule/gamelines/streaks/hotlist/ content/lines/books = 60/min. /api/analyze keeps its own 10/min.

Frontend ↔ Backend Wiring (Session 25 — non-obvious)

A new Express route under /api/* is NOT reachable from the browser until a matching Next.js proxy route exists at web/src/app/api/.../route.ts that forwards to ${BACKEND_URL}/api/.... The browser hits the Next origin, not Express directly. This bit us: schedule/gamelines/streaks/hotlist endpoints worked on Express but 404'd in the UI for two sessions. When adding a backend endpoint the frontend calls, ALWAYS add the proxy too (pattern: web/src/app/api/odds/nba/route.ts).

Tank01 betting-odds real shape: sportsbooks are TOP-LEVEL keys on each game object ({ awayTeam, homeTeam, bet365:{...} }), not a sportsBooks array. Filter NON_BOOK_KEYS to extract books (see gameLines.js).

VYNDR 2.0 Design System (Session 33 — Phase A+B)

Multi-session frontend conversion of the claude.ai/design "VYNDR 2.0" handoff (NOT a backend change). Foundation shipped; pages/mobile/systems are Sessions 34+. Source of truth = the prototype's vyndr.css + VYNDR_HANDOFF.md.

  • Tokens live in web/src/app/globals.css :root. The NEW canonical set is the short names: grades --g-ap/--g-a/--g-b/--g-c/--g-d, sports --s-nba/--s-mlb/--s-wnba/--s-soccer, --amber, --live/--hit/--miss, --scan-op (0.04), --glitch (1), --sans (Inter), --mono (JetBrains Mono). The legacy alias block (--grade-a, --nba, --accent, …) is KEPT — do not delete it until every consumer is migrated.
  • Two hard brand rules: (1) JetBrains Mono (var(--mono) / .mono) for ALL data — odds, %, timestamps, book names, stat lines, grades; Inter (var(--sans)) for everything else. (2) Glitch animations apply ONLY to chrome (wordmark, headers-on-hover, dividers, loaders, living layer). DATA NEVER GLITCHES.
  • Glitch keyframes (§4) are appended to globals.css after the legacy block — later-wins where names collide, so the appended VYNDR-2.0 definitions are authoritative. Entrance keyframes floor at the visible state (fade-in from opacity .6) so a paused frame is never invisible.
  • Shared components: @/components/vyndr/* (Wordmark, GradeBadge, SportBadge, TerminalInput, SectionHead, VBtn, Card, Sparkline, Ticker), helpers in web/src/lib/vyndrTokens.js. The NEW Wordmark is .wm markup at @/components/vyndr/Wordmark; the legacy @/components/Wordmark (.wordmark) is still used by Nav until pages convert. vyndrTokens.js is CommonJS so it's importable by .tsx (allowJs) AND requireable by the plain-JS Jest suite — keep helper logic there so it stays genuinely unit-testable.
  • a11y layer (§10): <html data-contrast|data-text|data-cb|data-font| data-motion> overrides in globals.css. Wiring the toggles to these attrs is Phase G (Session 38).

VYNDR 2.0 App Shell (Session 34 — Phase C)

The frame every page sits in. Frontend-only.

  • Routing config = web/src/lib/routes.js (CommonJS so it's unit-testable): GATED_ROUTES, OPEN_ROUTES, HASH_ALIASES, isGatedRoute(), resolveHashAlias(). GATED is deliberately narrow — only personal surfaces (ledger/tracker/account/profile/settings/notifications/invite). dashboard + scan stay OPEN (the free-scan funnel); gating them would be a monetization regression.
  • Auth gate = CLIENT-side (components/AuthGate.tsx, mounted around <main> in layout). Our Supabase session lives in localStorage, not an httpOnly cookie, and middleware.ts is locale-only — a server middleware can't read it. The gate uses useAuth().loading/user + isGatedRoute() and redirects to /login?next=<path> (the param /login already consumes). Do NOT try to move this to middleware without first adding @supabase/auth-helpers-nextjs + cookie sessions (a separate, larger change).
  • Hash deep-links: components/vyndr/HashRedirect.tsx translates #scan/#terminal/… → real Next routes once on mount. We keep file-based routing; hashes are just redirect aliases for old share links / PWA shortcuts.
  • Nav (components/Nav.tsx): uses the new @/components/vyndr Wordmark (.wm), mono uppercase links, active = --g-a, a More dropdown, and a Ticker under the bar. The fixed header is 60px nav + 32px ticker, so layout main paddingTop is 96 (not 64) — keep that in sync if the header height changes.
  • Footer (components/Footer.tsx) is mounted GLOBALLY in the layout (not per-page). System voice + "BUILT BY KEVON BUTLER · DETROIT".
  • 404 (app/not-found.tsx) is the north star — scanlines, crt-sweep, glitch wordmark, amber 404. Interactive CTAs live in client NotFoundActions so the page stays a server component (keeps its metadata export).
  • RouteStub (components/vyndr/RouteStub.tsx) backs not-yet-built routes (terminal/compare/invite/help/about/notifications). Never stub over a route that already has real content.

VYNDR 2.0 Core Screens (Session 35 — Phase D)

  • Grade Result Card = @/components/vyndr/GradeResultCard (the product's core moment) + ProcessingGrade (the factor-ignite reveal that precedes it). Feed them the §7 contract via lib/gradeAdapter.js (mapScanToGradeResult). The adapter is CommonJS (unit-testable) and tier-gates content: free = 3-signal teaser, no kill conditions, no alt ladder; analyst = full signals + kill conditions; desk = + alt ladder. Keep that gating — the card itself shows whatever it's given, so the giveaway-prevention lives in the adapter. GradeResultCard returns the inferred JS-object type loosely, so cast mapScanToGradeResult(...) as GradeResultData at call sites (the JS adapter has no TS types).
  • Scan grade flow (app/scan/page.tsx) now renders ProcessingGrade→ GradeResultCard. The existing search/limit/parlay logic, markReadComplete reads tracking, and noopener noreferrer sportsbook deep-links were preserved — don't drop those when iterating.
  • GameCard = @/components/vyndr/GameCard (Bloomberg best/worst line cells: best = rgba(0,212,160,.13) + green left border, worst = subtle red). Built + tested but NOT yet swapped into the live dashboard/Slate.tsx — that data-mapping swap is a pending task; don't assume the dashboard uses it yet.
  • Terminal (app/terminal/page.tsx) is a real page now (server component, sample intel data) — no longer a RouteStub. Real-data wiring is later.
  • ClaimMeter = @/components/vyndr/ClaimMeter (founder-seat scarcity), on the landing under the Hero.

VYNDR 2.0 Remaining Screens (Session 36 — Phase E)

  • Dashboard lineslib/slateAdapter.js (parseAmericanOdds, detectBestLines, mapScheduleToGameCards) is the testable best/worst-line engine. The LEGACY components/GameCard.tsx (used by the live Slate, with inline grading) was RESKINNED to render its game-lines grid via detectBestLines (best = green tint + green left border, worst = subtle red)
    • SportBadge. IMPORTANT: the live Slate still uses the legacy GameCard, NOT vyndr/GameCard — a full swap needs inline grading ported into the new component first (slateAdapter + vyndr/GameCard are ready for it).
  • Real pages (were RouteStubs): compare, invite, help, about. Only /notifications is still a RouteStub (keep the Session-34 stub test in sync if you convert it).
  • Reskinned (logic preserved): login (scanlines + "ACCESS THE SIGNAL"), pricing (+ ClaimMeter). account redirects to /profile.

VYNDR 2.0 Mobile Parity (Session 37 — Phase F)

  • Bottom tab bar = components/BottomTabBar.tsx: 5 tabs (Slate/Terminal/ Scan/Ledger/More), Scan is the prominent raised grade-green action, More opens an integrated bottom sheet. Shown for ALL users (anon included — it's the only mobile nav); hidden on auth flows + landing via HIDE_ON, and hidden ≥768px via the .mobile-tab-bar rule in globals.css. The Nav hamburger is retired on mobile (the tab bar owns nav); the Nav's mobile panel is now dead code.
  • Mobile CSS lives in the "MOBILE PARITY" section of globals.css: main bottom-padding clears the 64px bar + safe-area <768px; .grade-hero (the GradeResultCard letter) → 80px <640px; .terminal-grid stacks <768px; .game-lines-grid horizontal-scroll. Class hooks: grade-hero, terminal-grid.
  • PWA: manifest has shortcuts (Slate/Scan/Terminal) + categories [sports,finance,productivity]; layout viewport sets viewportFit: 'cover'.
  • BUILD GOTCHA: don't use as const on heterogeneous config arrays (TABS) — it makes each entry a distinct literal type and optional props fail type-check. Use a shared interface. And the build worker exits code 1 on type errors — check the build EXIT CODE, not just a | tail of its output.

VYNDR 2.0 Systems (Session 38 — Phase G)

Testable CommonJS modules in lib/ + thin React glue:

  • lib/parlayMath.js — frontend correlation model (player 0.62 / team 0.34 / league 0.06 / cross-sport 0), parlayGrade penalty, grade→odds. Backend parlayService (S28) still owns server combined odds.
  • lib/oddsFormat.jsfmtOdds(value, format). CRITICAL: only signed-int strings + integer numbers are odds; totals/lines/spreads pass through UNCHANGED (parseMoneyline). This is intentionally stricter than the prototype's parseAm (which would mis-convert 228.5) — keep it that way.
  • lib/prefs.jsapplyPrefs sets <html data-*> (the S33 CSS layer keys off these), load/save to localStorage('vyndr_prefs').
  • lib/liveTick.js — single tick store; never auto-starts (SSR/test-safe), start() runs the unref'd 1s interval, tick() emits a fresh state object.
  • lib/checkout.jscheckoutUrl(plan).
  • components/vyndr/LiveLayer.tsx (useLive/LiveNumber/HeartbeatBar) + GlobalHosts.tsx (mounted in layout — applies prefs, registers window.__prefs/__goPaywall/__checkout, hosts Prefs + Paywall modals).
  • Header is now 124px tall (nav 60 + ticker 32 + heartbeat 30): layout main paddingTop = 124, Slate sticky top = 122. Keep them in sync if header changes.
  • GOTCHA: don't return a Set.delete-based unsub directly from useEffect (returns boolean ≠ valid cleanup) — wrap as () => { unsub(); }.

VYNDR 2.0 Conversion COMPLETE (Session 39 — Phase H QA)

The 7-session design conversion (3339) is done and parity-verified against §13.

  • Parity invariants are locked by tests/unit/vyndrParityQA.test.js — if you later add a glitch class to a data component, a raw #00ffb8/sport hex, a dead onClick={}, or break the gated-route list, that suite fails. Keep it green.
  • INTENTIONAL hex (do NOT "fix" to tokens): var(--token, #fallback) fallbacks, Next metadata themeColor, and the bespoke intel-surface/red-tint text shades (#e8fff4/#bdf5e2/#ff8a8a/#ff8b7a/#ffb0a4/#ffd9a8/#04140f) ported from the prototype — no token equivalent.
  • The GradeResultCard hero LETTER is intentionally var(--sans) (display), matching the prototype; all grade DATA rows + the GradeBadge chip are mono.
  • DEFERRED (never in 3339 scope): a true ⌘K command palette (Nav Query currently links to /scan).
  • TEST DE-FLAKE: soccerFeatureExtractorCascade gets jest.setTimeout(20000) — it falls through to live adapters on cache miss and flaked at Jest's 5s default under full-suite load (same family as the S32 pipeline test).

P0 Audit Fixes (Session 41 — non-obvious)

  • THREE stat_type whitelists must stay in sync. A prop's stat_type is gated in src/routes/analyze.js (/prop + /batch), src/routes/scan.js (parlay legs), AND src/services/python/utils/validation.py. Adding a sport's stats to one without the others silently 400s. The S41 MLB bug was exactly this: Python had the MLB set; both Node gates didn't. mlbGrader.js is DEAD CODE (required nowhere) — the live MLB grade path is the generic engine1 feature pipeline, which keys off the Python validator's exact stat names (rbi/runs/innings_pitched, NOT rbis/runs_scored/outs_recorded).
  • tests/integration/analyze.test.js sits exactly at the 10-req/min IP rate limit (createRateLimit max:10, no reset hook). Adding any HTTP test there 429s the later cases. Put new analyze-route HTTP tests in a SEPARATE file (jest isolates module state per file → fresh limiter): see tests/integration/analyzeMlbStats.test.js.
  • Fonts are self-hosted via next/font/google (layout.tsx): Inter→ --font-sans, JetBrains_Mono→--font-mono, IBM_Plex_Mono→--font-ibm, all set on <html className>. globals.css :root maps --sans/--mono/ --ibm-mono onto them. CRITICAL: next/font OBFUSCATES family names, so literal 'JetBrains Mono'/'IBM Plex Mono' in CSS or inline fontFamily no longer resolve — always reference the variable. The old fonts.googleapis.com CDN <link> is GONE (it 503'd in prod). ShareCard canvas still uses literal names (canvas can't read CSS vars) — knowingly left.
  • Redirect routes use server-component redirect() from next/navigation (/settings/profile, /report/blog). /settings/security is a REAL MFA enrollment page — do NOT clobber it into a redirect.
  • Profile tier reads from useAuth().tier (the nav's source), not the /api/user/profile fetch, so the two can't disagree.

Player Intelligence System (Session 42 — non-obvious)

Built from the Claude Design "VYNDR Player Intelligence" bundle.

  • Archetypes (41, NOT 45) — 15 NBA + 5 WNBA-unique + 15 MLB + 6 soccer. The canonical registry is src/services/archetypeService.js (ARCHETYPES keyed by UPPERCASE name; classify/getArchetype). The frontend visual map (color + glyph SVG + desc) is duplicated in web/src/lib/archetypes.js because the browser can't import the backend; a test asserts the colors MATCH. Colors are unique WITHIN a sport but REUSED across sports (a TWO-WAY archetype is purple in NBA + WNBA) — don't "dedupe" them. classify(sport, stats) is a feature-scoring classifier → { primary, secondary|null, blend: [{archetype, weight}] }.
  • StatStrip rule — player name appears ONCE; stats are horizontal mono runs. Never stack the name per stat. components/vyndr/StatStrip.tsx (compact + expanded). vyndr/GameCard prefers playerStrips (grouped) over legacy per-prop rows; both kept for back-comat.
  • Stats APIsrc/routes/stats.js ALREADY existed (filters/public/live); Session 42 ADDED /player/:name, /leaders, /game/:id (don't recreate the file). Aggregation logic is in src/services/playerIntelService.js (testable; inject cacheGet). The name param is sanitized there (strip non-name chars, cap 60). Reads grades:{sport} cache for a player's props. Frontend needs the Next proxy (app/api/stats/player|leaders/route.ts) — Express isn't reachable from the browser directly (same rule as S25).
  • Player links — always via web/src/lib/playerHref.js/player/:name?sport=.
  • GradeResultCard / gradeAdapter — new card sections (archetypeBlend, propDNA, statContext, vyndrIntel) are OPTIONAL + self-hiding; gradeAdapter.buildIntelFields only populates them when the engine supplies archetype/season_avg/form/etc. They light up once the Session-43 data pipeline feeds them — no empty boxes now.
  • Settings/settings is now a REAL page (replaced the S41 redirect). It LINKS to /settings/security (the real MFA page) — never overwrite that. Danger zone delete is gated on deleteText === 'DELETE'; there's NO backend deletion endpoint yet, so the confirmed action surfaces an honest "email support" message rather than faking success.
  • Components added to the barrel: ArchetypeBadge, ArchetypeBlend, StatStrip, BookChip (@/components/vyndr). Book brand map = web/src/lib/books.js.

Data Pipeline Wiring (Session 43 — non-obvious)

  • Dropdown z-index P0 — the <nav> uses backdrop-filter, which creates a stacking context. The living-layer bars (Ticker, HeartbeatBar) render as siblings AFTER it, so dropdowns that overflow below the 60px nav got covered. The nav carries position:relative; zIndex:2 to float above them — don't remove it, and keep dropdown menus at zIndex:100.
  • MLB real statsmlbStatsAdapter is id-keyed; Session 43 added searchPlayer(name) (resolves via the cached season player list) + getPlayerStats(name). playerIntelService.resolvePlayerStats(name, sport) normalizes the raw statsapi.mlb.com object into the archetype classifier's input shape + display rows. getPlayerIntel classifies from REAL stats now. Adapters are injectable (opts.mlbAdapter/opts.resolveStats) — tests never hit the network. NBA/WNBA uses nbaStatsClient (Python service, usually offline in prod → degrades to found:false, NOT an error).
  • Grade-card intelanalyzeViaEngine1.buildIntelFields(features) computes stat-context + form/usage/matchup/rest from the EXISTING feature vector (no extra I/O) and Object.assigns them onto the legacy result. gradeAdapter maps them into the card. Archetype is deliberately NOT attached at grade time (the per-prop feature vector has no multi-stat season line) — that strip waits for the Session-44 pipeline. Keep grade-time I/O at zero (slate grades in tight loops; this is why archetype isn't fetched per-prop).
  • slateAdapter now emits playerStrips (groupPropsByPlayer) + pitchers (mapPitchers) on every card. The LIVE slate still uses the legacy components/GameCard (which ignores those + renders BookChip in its line grid); the full swap to vyndr/GameCard is still pending (needs inline grading ported). mapPitchers returns undefined for non-MLB / no probables.
  • depthChartService (getLineup/getDepthChart/getCascadeProjection) + /api/stats/lineup|depth|cascade are the foundation; all graceful + injectable. 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).

Snapshot Pipeline (Session 45 — the product model)

The on-demand "Read" grade flow is RETIRED. Grades are produced by a scheduled snapshot, locked to the line, and read from cache.

  • snapshotService.runSnapshot(sport) orchestrates EXISTING services (don't rebuild): getOdds → gradeAndCacheSlate (captured via an injected cacheSet, so we re-write an ENRICHED envelope) → classify archetype per player (resolvePlayerStats + archetypeService) → attach gradedAt {line,odds,timestamp}computeLineDeltas vs previous → write snapshot:{sport}:latest|previous + grades:{sport}generateTickerEventsticker:items. ALL deps injectable → unit-tested with zero network. runAllSnapshots() is the cron entrypoint.
  • Redis keys: snapshot:{sport}:latest (current locked snapshot: {grades, deltas}), :previous (for the next delta), grades:{sport} (enriched, read by GameCard/Explore/leaders), ticker:items (capped 50 array).
  • Trigger is INTERNAL-ONLY: POST /api/internal/snapshot/:sport|/all behind requireInternalAuth (header x-internal-key == VYNDR_INTERNAL_KEY). /all is registered BEFORE /:sport or Express captures "all" as a sport.
  • Cron: src/snapshotScheduler.js, gated SNAPSHOT_CRON=1, UTC hours 14,19,22,1,3, armed in server.js (NOT app.js — app.js is imported by tests). No new dependency. For multi-replica, use an external n8n cron hitting the internal endpoint instead.
  • Read path: GET /api/snapshot/:sport (public, cache-only, never triggers a snapshot → can't drain PropLine). GET /api/ticker (public, merges TICKER_MANUAL env pins).
  • GameCard swap: the live Slate renders vyndr/GameCard (legacy GameCard kept for TYPES only — import type). It overlays snapshot grades onto each game's odds-derived props via slateAdapter.buildPlayerStripsFromProps (player name once + archetype + locked grade + line-delta sub-line). Ungraded → "Awaiting next scan", NO Read button. StatStrip renders the gradedAt/delta sub-line when a prop carries gradedAt/delta/awaiting (snapshot mode), else the inline chip.
  • Ticker (vyndr/Ticker) polls /api/ticker every 30s; the passed items are the initial + graceful fallback (never blanks on fetch failure).
  • NBA/WNBA stats: espnStatsAdapter is the FREE fallback when the Python nba_api service is offline. parseAthleteStats is DEFENSIVE (null on any shape it doesn't recognize → found:false). Its live ESPN shape may need prod tuning.
  • Env: PROPLINE_API_KEY_1/2/3, VYNDR_INTERNAL_KEY, SNAPSHOT_CRON=1, SNAPSHOT_HOURS_UTC (optional), TICKER_MANUAL (JSON array).

Grade Intel + Name Norm + Pitchers (Session 46 — non-obvious)

  • Grade-card intel root cause: buildIntelFields reads l5_avg/l20_avg/ opp_rank_stat/rest_days from the feature vector. gameLogService.getGameLogs is NBA/WNBA-ONLY (offline Python service) → MLB props had NO recent/season averages → intel always empty. FIX: featureCache.gameLogFeatures has an MLB branch using mlbStatsAdapter.getPlayerStats + the pure mlbGameLogFeatures (MLB stat_type→game-log field via MLB_LOG_FIELD). If you add an MLB stat type, add it to MLB_LOG_FIELD or its intel won't compute. buildIntelFields also takes { playerStats, projection } fallbacks — don't remove the resilience.
  • Player name normalization: src/utils/playerName.js is the source of truth (frontend copy at web/src/lib/playerName.js — keep them identical; a test cross-checks). normalizeName(raw)→{display,key}: display strips periods + de-dots suffix (keeps accents); key accent-folds + suffix-strips for comparison. Used in snapshotService grouping, slateAdapter grade-index + player-strip merge, and playerIntelService. sanitizePlayerName now returns the de-dotted display ("A.J. Ewing"→"AJ Ewing") — tests that asserted the dotted form were updated.
  • MLB pitchers: the ESPN /api/schedule has NO probable pitchers. They come from GET /api/schedule/:sport/pitchers (MLB) → probablePitchers service → mlbStatsAdapter.getScheduleWithPitchers. The Slate matches them to games by team (full name OR mascot via slateAdapter.buildPitcherMap/pitchersForGameTeams). ERA is best-effort (season stats per pitcher id, cached).

Active Skills

  • vyndr-voice (all user-facing output)
  • prop-analysis (grading methodology)
  • monetization-system (scan-5 pitch, tier conversion)