Files
vyndr/CLAUDE.md
T
builtbykev cdedecf55b Session 52: Coming Soon teaser + infrastructure verification (2239 tests)
Phase 1 — Push-to-Book teaser (feature not live; teaser only):
- StatStrip: "BOOK IT ⟶" per graded prop (hover: "Push-to-Book coming soon").
- GradeResultCard: "PUSH-TO-BOOK · COMING SOON" footer.

Phase 2 — infrastructure verification:
- snapshotScheduler logs armed AND disarmed state (incl SNAPSHOT_CRON) so
  container logs disambiguate off-vs-crashed.
- NEW GET /api/internal/snapshot/status (internal-key gated): cron_armed,
  cron_hours_utc, last_snapshot per sport (gradeCount/deltaCount), redis_keys
  existence map, ticker_count. The post-deploy pipeline health probe.
- Finding: Redis AOF/RDB persistence is a server-side (Coolify) config the app
  can't set/verify — documented.

Phase 3 — delta pipeline (verified sound, no fix needed):
- runSnapshot already rotates :latest->:previous and diffs locked lines; added
  opt-in SNAPSHOT_DEBUG=1 [deltas] log + a trace test asserting :previous is
  preserved verbatim and the delta math is correct.

Backend 2234 -> 2239 tests (+5), 192 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:55:39 -04:00

600 lines
38 KiB
Markdown
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 stats** — `mlbStatsAdapter` → 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 enrichment** — `scheduleService.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'`.
- **Writer** — `gradeSlateService.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 lines** — `lib/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.js`** — `fmtOdds(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.js`** — `applyPrefs` 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.js`** — `checkoutUrl(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 API** — `src/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 stats** — `mlbStatsAdapter` 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 intel** — `analyzeViaEngine1.buildIntelFields(features)` computes
stat-context + form/usage/matchup/rest from the EXISTING feature vector (no
extra I/O) and `Object.assign`s 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}` → `generateTickerEvents` → `ticker: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).
## Name Norm + Intel + Ticker (Session 47 — non-obvious)
- **Name normalization is now complete:** `playerName.js` (both copies, kept
identical) strips parenthetical team tags ("(STL)"), de-dots, suffix-strips,
accent-folds the key, AND resolves first-name nicknames via the `NICKNAMES`
table ("matt"→"matthew"). `nameKey` does the nickname resolution; `normalizeName`
does display/key. To add a nickname, edit BOTH copies. The SLATE displays the
normalized de-dotted name (`buildPlayerStripsFromProps` → `normalizeName().display`),
not raw PropLine — that's why "AJ Ewing" shows, not "A.J. Ewing".
- **MLB VYNDR INTELLIGENCE fields** come from `mlbGameLogFeatures`: `rest_days`
(gap-1 between the two latest game dates; 0 = B2B), `ab_per_game` (usage). If a
field is missing from the card, check that `mlbGameLogFeatures` produced it and
`buildIntelFields` has a branch (usage reads usage_rate→minutes→ab_per_game;
matchup reads opp_rank_stat→bvp_advantage).
- **Ticker SCAN dedup:** `pushTickerItems` keeps one SCAN per sport (via the
`sport` field on the event, or parsed from the text prefix for legacy items).
MOVE/GRADE are time-specific and never deduped.
- **BOMBER threshold is prorated for mid-season** (`hr>=15` strong / `hr>=10`
moderate). If you re-tune archetype thresholds, remember season totals are
partial mid-season — don't use full-season cutoffs.
## Normalization Chokepoints (Session 48 — non-obvious)
- **The snapshot is the normalization SOURCE.** `snapshotService.runSnapshot`
normalizes every grade's player to `normalizeName().display` AND dedupes to one
grade per `nameKey|stat_type` (highest confidence) before writing
`grades:{sport}` + `snapshot:{sport}:latest`. So GameCard overlay, Explore,
leaders, and profile activeProps ALL inherit clean, merged names — don't add
per-consumer normalization, fix it here.
- **Three paths that needed it (all fixed):** snapshot grades (above);
`buildPlayerStripsFromProps` dedupes a player's props by stat (graded > awaiting);
scan `tonightsPlayers` groups by `nameKey`. If a NEW surface lists players,
group by `nameKey` + display `normalizeName().display`.
- **TWO intel renderers — don't confuse them:** the GRADE CARD (scan) uses
`analyzeViaEngine1.buildIntelFields(features)`; the PLAYER PROFILE uses
`playerIntelService.buildIntel(stats)`. The "+0%"/"—" bug was the PROFILE's
defaults — fixed by `resolvePlayerStats` attaching real `usage` (AB/G) + `rest`
and `buildIntel` reading them. The grade card already worked (the full feature
merge carries ab_per_game/rest_days; `FEATURE_NAMES` is meta bookkeeping only,
NOT a whitelist that filters the vector).
## Onboarding Flow (Session 49 — non-obvious)
- **Preferences live in Supabase `user_metadata.preferences`** (NO table/migration).
`src/routes/preferences.js` GET/POST behind `requireAuth`, read/written via the
service client's `auth.admin.getUserById`/`updateUserById`. POST is a PARTIAL
merge (only body keys change) + sanitized (valid sports only, ≤12 books, clamped
limit). Shape: `{ sports[], books[], weekly_limit, onboarding_complete }`.
- **Redirect reads FRESH from the API, not session metadata.** The dashboard
fetches `/api/preferences` on mount and redirects new+incomplete users to
`/onboarding`. Don't rely on `session.user.user_metadata` for this — it's stale
after a backend write (admin API doesn't refresh the client session).
- **Existing users are exempt** via a `created_at >= ONBOARDING_CUTOFF`
(2026-06-19) check. The redirect effect also early-returns while `authLoading`
(else it bounces unauthenticated users to onboarding instead of login).
- **Dashboard personalization:** Slate `initialTab = prefs.sports[0]`; preferred
books highlight in the card lines grid via `lib/books.js isPreferredBook`
(matches DK/draftkings/DraftKings), threaded dashboard → Slate → vyndr/GameCard.
- **Settings** has a PREFERENCES section (GET to load, POST to save) — the same
sports/books/limit the onboarding collects.
- **Name micro-fix:** `playerName.js` `collapseInitials` merges "J C" → "JC"
(display + key) so space-separated initials dedupe.
## Parlay Lab (Session 50 — non-obvious)
- **Correlation model** lives in `src/services/parlayService.js` (ADDED to the
S28 categorical matrix — both coexist). `correlationScore(l1,l2)` is numeric +
GAME-aware (0.7 same-player/game, 0.4 same-team/game, 0.2 same-game, 0.0 diff-
game). `combinedGrade` penalizes the leg-grade avg by `avgCorrelation*0.5`;
`estimatedPayout` = Πfair-odds × (1avgCorr) discount. `gradeParlay` is the
bundle the route returns. Leg shape: `{ player, team, game, stat, grade }`.
- **POST /api/parlay/grade** (`src/routes/parlay.js`) — public, 26 legs, returns
`{ combined, correlation, payout, legs }`. The Next proxy was pointed at the
WRONG upstream (`/api/scan/parlay`); it now forwards to `/api/parlay/grade`.
- **ParlayContext auto-grades** the slip (debounced 250ms) via that endpoint
whenever legs change (≥2). `combined`/`correlation`/`payout` are live on the
context — don't call the endpoint from components, read the context.
- **Leg cap is tier-aware via the context.** `maxLegs` defaults 6; `ParlayPanel`
sets it from `useAuth().tier` (free 2 / analyst 4 / desk 6). `addLeg` reads a
ref so it stays a stable callback. The "+" buttons no-op at the cap.
- **"+" wiring:** `StatStrip` takes `onAddLeg`/`isLegActive`; `vyndr/GameCard`
provides them via `useParlay` (it owns the game id + team). `legKey` =
`player|stat|line|direction` is the dedupe key (exported from the context).
- **ParlayPanel** (mounted in layout, REPLACED `ParlayTray` which called a
then-missing `/grade` endpoint). Floating badge bottom-right when closed; free
tier blurs the payout with a `window.__goPaywall` upsell.
## Team Hub (Session 51 — non-obvious)
- **`teamService.getTeamHub(sport, abbr)`** is the single payload builder for
`/team/:abbr`. MLB is the real path: `mlbStatsAdapter.resolveTeam` (abbr→id via
the cached `/teams` list) → `getTeamRoster` → per-player `getSeasonAverages(id)`
(bounded concurrency 8, reuses `playerIntelService._internals` mappers) →
archetype (the snapshot grade's locked archetype, else `classify`) → graded
props from `grades:{sport}`. The whole hub is cached 15 min (`teamhub:{sport}:
{abbr}`); each player's season stats cache 6h. NBA/WNBA have no free roster
feed → it returns a partial roster built from tonight's graded players + a note.
All deps injectable for tests.
- **To add a sport's roster**, add a real roster source in `getTeamHub` (the MLB
branch is the template); abbr→id mapping is fetched live from statsapi (no
hardcoded team table to maintain).
- **`GET /api/team/:abbr`** (public, cached) 404s an unknown MLB team. Browser
must use the Next proxy `app/api/team/[abbr]`.
- **Page split:** `team/[abbr]/page.tsx` is a server wrapper (for
`generateMetadata`) rendering the `TeamHub` client component (sort/filter/parlay
are interactive). Player names → `playerHref`; team abbrs on game cards →
`vyndr/GameCard`'s `TeamLink` (stops propagation from the open-game handler).
The roster "+" reuses the Parlay Lab (`useParlay`/`legKey`).
## Infra Verification (Session 52 — non-obvious)
- **`GET /api/internal/snapshot/status`** (internal-key gated) is the post-deploy
health probe: `{ cron_armed, cron_hours_utc, last_snapshot, redis_keys,
ticker_count }`. Use it to confirm the snapshot pipeline is alive without
shelling into the container.
- **Scheduler logs both states** on start: armed (`[snapshotScheduler] armed —
SNAPSHOT_CRON=…`) and disarmed. If you see neither in container logs,
`startSnapshotScheduler()` isn't being called.
- **`SNAPSHOT_DEBUG=1`** turns on a per-run `[deltas]` log in `computeLineDeltas`
(off by default — it's a hot path). Confirms a previous snapshot exists to diff.
- **Redis persistence is NOT app-controlled.** ioredis connects via `REDIS_URL`;
AOF/RDB is a server-side Coolify config. Snapshot keys disappearing on restart
= the Redis instance has persistence off, not a code bug.
- **Push-to-Book is a TEASER only** — "BOOK IT ⟶" (StatStrip) + "PUSH-TO-BOOK ·
COMING SOON" (GradeResultCard) advertise an unbuilt feature. No backend.
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
- monetization-system (scan-5 pitch, tier conversion)