7a35c96781
- QA.20: one meaning per color, locked — STEAM amber (caution), VALUE green (edge), red only at STALE/miss; revisions render the original grade struck through on slate AND ledger. - QA.21: every new data surface (PriorReads, PlayerStreaks, ModelRecord) renders data in mono (tabular-nums via the .mono class). - QA.22: waiting copy derives from the real cron schedule on every surface (StatStrip, dashboard, Explore) — no passive mystery anywhere. - Explore empty state joined the honest-waiting lib. - Sweep result: no raw brand hexes and no sans-serif data in tonight's components (sans only on prose/captions/hero letter — sanctioned); glow confined to A-tier badges + reveal chrome; 390px containment holds (wrapping chips, scroll-contained tables from Phase 3 rules). Backend 2352 -> 2396 tests (205 suites), web build exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
808 lines
53 KiB
Markdown
Executable File
808 lines
53 KiB
Markdown
Executable File
# 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 (33–39) 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 33–39 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.
|
||
|
||
## Name Edge Cases + Audit Polish (Session 54 — non-obvious)
|
||
- `normalizeName` now also **strips hyphens** to spaces ("Jung-hoo" → "Jung hoo")
|
||
in display+key; `nameKey` **strips single-letter MIDDLE tokens** ("Josh H Smith"
|
||
→ "josh smith") — keeps first (may be a collapsed initial like "jc") + last, so
|
||
real middle names ("Juan Carlos Smith") and first initials are NOT dropped.
|
||
`richie: 'richard'` added to NICKNAMES. Both copies kept identical.
|
||
- **Accent-keep dedup** (`snapshotService`): the variant-grade collapse keeps the
|
||
highest-CONFIDENCE grade but the richest DISPLAY (prefers accented "José" over
|
||
"Jose", then longer). That's why prop rows now match the accented pitcher line.
|
||
`hasAccent` uses a charCode>127 check (NOT a regex with literal control bytes).
|
||
- **Team Hub names** are normalized in `teamService.getTeamHub` (the source), not
|
||
the page — every `/api/team/:abbr` consumer gets `normalizeName().display`.
|
||
|
||
## 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 × (1−avgCorr) discount. `gradeParlay` is the
|
||
bundle the route returns. Leg shape: `{ player, team, game, stat, grade }`.
|
||
- **POST /api/parlay/grade** (`src/routes/parlay.js`) — public, 2–6 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.
|
||
|
||
## Social Preview / OG Image (Session 53 — non-obvious)
|
||
- The OG/Twitter image is **dynamically generated** by the file-based convention
|
||
`web/src/app/opengraph-image.tsx` (Twitter re-exports it). Do NOT re-add an
|
||
`images: ['/og-image.png']` to the `layout.tsx` metadata — that emits a second,
|
||
conflicting `og:image` tag. The legacy static `/og-image.png` still exists in
|
||
`public/` but is unreferenced.
|
||
- It uses **Node runtime, NOT `runtime = 'edge'`** — this is a self-hosted
|
||
`output: 'standalone'` build; edge runtime isn't available off Vercel and breaks
|
||
`next/og` here. Don't copy the edge example from Next docs.
|
||
- Social copy is intentionally jargon-free (no "Bayesian"/"kill conditions"/"xG
|
||
regression"). "kill conditions" is still legit IN-APP product copy (help/pricing/
|
||
scan) — only the social meta was cleaned.
|
||
|
||
## Self-Learning Loop + Real-Time Layer (Session 55 — non-obvious)
|
||
- **`outcomeService.settleSnapshot(sport, deps)`** is the self-learning loop:
|
||
reads `snapshot:{sport}:latest`, settles each locked grade vs the REAL result
|
||
from `mlbStatsAdapter.getPlayerStats().last10` (game-log row whose `date`
|
||
matches the graded date — UTC **or** ET, to cover late-game rollover), records
|
||
hit/miss/push, and writes `outcomes:{sport}:log` + `accuracy:{sport}` +
|
||
`accuracy:overall`. **Idempotent** — dedupe key is `nameKey|stat|line|side|date`;
|
||
re-running never double-counts. Presence of a game-log row ⇒ the game is FINAL
|
||
(no separate status check). NBA/WNBA have no free settled-result feed → they
|
||
stay `pending` (never throw). If you add an MLB stat_type, add it to
|
||
`MLB_LOG_FIELD` in outcomeService (a LOCAL copy — settlement is decoupled from
|
||
featureCache's map on purpose) or its props won't settle.
|
||
- **Accuracy pct EXCLUDES pushes** (`hits/(hits+misses)`); `sample` counts pushes.
|
||
Grade buckets: `A+` stands alone, then first-letter (`A-`/`A`→A, `B±`/`B`→B).
|
||
30-day trailing window keyed off each outcome's game `date`.
|
||
- **The cron settles BEFORE it grades** (`snapshotScheduler` tick): settle
|
||
yesterday's now-completed games, then grade today's fresh slate. Trigger
|
||
manually via internal `POST /api/internal/outcomes/all` (same key as snapshot).
|
||
- **`GET /api/accuracy`** (public, cached) + **`GET /api/ledger/accuracy`**
|
||
(buckets — the pre-existing Next `ledger/accuracy` proxy finally has a writer).
|
||
Browser reaches them via `web/src/app/api/accuracy/route.ts` (new proxy) — same
|
||
S25 rule.
|
||
- **`AccuracyBadge`** (`@/components/vyndr`) is HONEST: `< MIN_SAMPLE` (8 settled)
|
||
→ "MODEL · LEARNING" (amber), else "A-RATED · X% HIT · 30D" (green). Pass
|
||
`sport=` for a sport-specific record. Self-hides only when the fetch fails/empty.
|
||
- **Settled outcomes overlay the live slate:** `GET /api/snapshot/:sport` merges
|
||
`outcomes:{sport}:log` onto grades (`outcome:{result,actual}`), threaded through
|
||
`slateAdapter.buildPlayerStripsFromProps` → `StatStrip.OutcomeChip` (`✓ HIT (2)`
|
||
/ `✕ MISS`). A settled prop hides its "+parlay" / BOOK-IT chips (the bet is over).
|
||
- **Real-time Slate:** `fetchSlate(tab, silent=true)` polls every 60s WITHOUT the
|
||
skeleton flash and NEVER wipes a good view on a transient empty result (guard:
|
||
`if (silent && allGames.length===0) return`). The "SIGNAL LIVE · UPDATED Xs ago"
|
||
strip uses `lastRefreshed` + a 15s `nowTick`. Ticker has an anchored LIVE badge
|
||
that flashes on a new head event.
|
||
- **Founder pricing:** `stripeService` `FOUNDER_CODE_EXPIRY` default is now
|
||
`2026-12-31` (was `2026-06-30`, which had lapsed and disabled every founder code
|
||
+ the ClaimMeter pitch). That expiry lapsing — NOT a tier-limit change — was the
|
||
cause of the "4 stripe test failures." Operators override via `FOUNDER_CODE_EXPIRY`.
|
||
|
||
## Data Audit + Pipeline Resilience (Session 56 — non-obvious)
|
||
- **`specs/propline-audit.md` + `specs/vyndr-roadmap.md` are the source of truth**
|
||
for data coverage + the session plan. Re-run `node scripts/propline-audit.js`
|
||
(writes raw JSON to stdout; live sections need network) to refresh.
|
||
- **A market only fully works if it's wired at FOUR layers:** requested in
|
||
`proplineAdapter.MARKETS`, mapped in `oddsNormalizer.MARKET_MAP`, whitelisted in
|
||
all three grade gates (`routes/analyze.js`, `routes/scan.js`,
|
||
`python/utils/validation.py`), AND (for MLB) present in `MLB_LOG_FIELD` in BOTH
|
||
`featureCache` and `outcomeService`. Miss the map → silent zero; miss the log
|
||
field → no features + no settlement. `batter_rbis` was mapped to `rbis` while
|
||
everything else keyed on `rbi` — a 4-layer desync that silently killed RBI
|
||
props. It's `rbi` now; a normalizer test locks it.
|
||
- **The streaks/hotlist path uses its own `rbis` key** built from raw MLB stats —
|
||
independent of the odds normalizer. Don't "unify" them; the split is intentional.
|
||
- **MLB is the ONLY end-to-end-live sport.** Outcome settlement is MLB-only
|
||
(WNBA/NBA/soccer grades never settle → `accuracy` reflects MLB only). Fixing
|
||
that (ESPN box-score settle path) is roadmap Session 57.
|
||
- **`src/utils/opsNotify.js`** pushes pipeline alerts to ntfy (`vyndr-pipeline-
|
||
kev2026`). It NEVER throws and is auto-disabled under `NODE_ENV==='test'` /
|
||
`PIPELINE_ALERTS=0` (inject `fetchImpl` to test it). `snapshotService` alerts on
|
||
success/stale/failure; `snapshotScheduler` has a per-minute missed-cron watchdog
|
||
(`isSnapshotOverdue`, exposed as `overdue` on the status probe).
|
||
- **Snapshot retry rule:** retry-once ONLY on a thrown/null odds response (a
|
||
transient provider blip). A successful-but-empty slate is NOT retried — that's a
|
||
legit off-hours empty slate, and retrying would waste quota + add 60s latency.
|
||
|
||
## Phase 0 — Kill the Lies (Session 57 — non-obvious)
|
||
Work-order Phase 0 (Jul 10 live audit): fabricated UI deleted/rewired. The
|
||
overhaul work order lives in `specs/phase-0-kill-the-lies.md` (+ the full
|
||
phased plan in the Session-57 conversation / BUILD-STATE Next section).
|
||
- **ESPN schedule is date-pinned now.** `fetchScheduleFromEspn(sport, date)`
|
||
sends `?dates=YYYYMMDD` AND filters events to the requested ET date (undated
|
||
events dropped). Without this, off-season ESPN returns the NEAREST slate —
|
||
the Jun 13 NYK@SA Finals game rendered as "tonight" all July. Don't remove
|
||
the defensive filter even though the param "should" be enough.
|
||
- **`liveTick` carries NO display data** — state is `{ tick }` only (1s
|
||
re-render pulse). Real header numbers come from `GET /api/snapshot/summary`
|
||
(public, cache-only; registered BEFORE `/:sport` in `routes/snapshot.js`).
|
||
`vyndrSystems.test.js` fails if fabricated counters reappear.
|
||
- **Ticker hides below 4 real items** (spec §3) and publishes `--ticker-h`
|
||
(32px/0px) on `<html>`. Layout main paddingTop = `calc(92px +
|
||
var(--ticker-h, 0px))`, Slate sticky top = `calc(90px + ...)`. If you change
|
||
header heights, update BOTH calc bases (they replace the old 124/122
|
||
constants). No hardcoded fallback ticker items — real exhaust or nothing.
|
||
- **/terminal redirects to /dashboard.** The old page's layouts (VVI cards,
|
||
injury wire, leaders) are preserved UNROUTED in
|
||
`web/src/components/intel/TerminalTemplates.tsx` as §12 content-engine
|
||
templates — every constant in there is SAMPLE data; never route it as-is.
|
||
BottomTabBar's 5 tabs are now Slate/Explore/Scan/Ledger/More.
|
||
- **Empty slates use `web/src/lib/emptyState.js`** (month-aware per-sport
|
||
copy). It's CommonJS + unit-tested; add sports there, not inline.
|
||
|
||
## Phase 1 — Truth Infrastructure (Session 58 — non-obvious)
|
||
- **DATA SEMANTICS RULE (system-wide):** VYNDR never generates lines/odds —
|
||
market values are REAL book numbers captured at a timestamp; only
|
||
`model_value`/grade/edge/confidence are model output, always labeled MODEL
|
||
in the UI. Any path that would fill in a market value must show an absent
|
||
state. `Number(null) === 0` is the classic fabrication bug — use
|
||
`numOrNull` (ledgerService) style strict parsing.
|
||
- **`ledger_entries`** (migration 019, APPLIED): user_id NULL rows = the
|
||
PUBLIC model record (pipeline-only — never write anon scans there!).
|
||
Dedupe = `UNIQUE NULLS NOT DISTINCT (user_id, player_key, stat, line,
|
||
side, game_id)`; upserts use `ignoreDuplicates: true` so re-runs never
|
||
overwrite the original lock. RLS: clients read own rows + public rows;
|
||
ALL writes via service role. player_key = `nameKey()`.
|
||
- **Write paths:** pipeline → `snapshotService` calls
|
||
`ledgerService.recordPipelineGrades` + `captureClosing` (best-effort,
|
||
never breaks the snapshot; no-op without SUPABASE env). User scans →
|
||
the NEXT `/api/scan` route (`writeLedgerEntry`), authed users only.
|
||
- **Closing/CLV:** captureClosing overwrites today's unsettled rows'
|
||
closing_line/odds on EVERY snapshot — last write before game start is the
|
||
close. CLV is SIGNED BY SIDE: over = locked − closing (positive = market
|
||
moved toward the grade = 'beat'); under = inverse. Settlement
|
||
(`settleLedger`) runs in the scheduler's pre-grade settle pass and via
|
||
POST /api/internal/ledger/settle; idempotent (`.is('outcome', null)` guard).
|
||
- **Insufficient data (work-order 1.5):** `analyzeViaEngine1` REFUSES
|
||
(grade null + `insufficient_data: true`) when `projectionFor` finds no
|
||
model reference (l5→l20→`{stat}_per_90`→xG-for-goals). gradeSlateService
|
||
filters refusals out of the slate; parlay legs degrade to the F-stub; the
|
||
scan route writes nothing and doesn't burn a scan. If you add a sport,
|
||
make sure its feature extractor emits a projection field or every prop
|
||
refuses.
|
||
- **n≥20 rule:** `getModelAggregate` returns `hit_pct`/`beat_close_pct` as
|
||
NULL under 20 settles; every surface (ledger MODEL tab, `ModelRecord`)
|
||
renders "RECORD BUILDING" instead. Never render a % on a small sample.
|
||
- **SYNC thresholds** come from `SNAPSHOT_EXPECTED_INTERVAL` (s; default
|
||
18000) via /api/snapshot/summary `expected_interval_s`. Phase 2.5 drops
|
||
the value to the intraday cadence — no UI change needed.
|
||
|
||
## Player Keys + Slate Join + Mobile Nav (Session 59 — non-obvious)
|
||
- **`mlbStatsAdapter.searchPlayer` resolves via `nameKey`** (canonical,
|
||
accent-folded). NEVER reintroduce substring matching — it returned the
|
||
WRONG player's id on a near-miss (the audit's mismatched last-10 bug).
|
||
Fallback is unique last-name+first-initial or null: a missing profile
|
||
beats another player's log.
|
||
- **The slate JOIN INVARIANT** lives in `buildPlayerStripsFromProps(…,
|
||
gameTeams)`: a graded prop whose player's real team (snapshot `team`,
|
||
from the stats resolve) isn't a game participant is DROPPED. Props with
|
||
no team info are kept (can't verify ≠ wrong). Tests in
|
||
slateAdapterStrips lock it.
|
||
- **grades:{sport} is written with SNAP_TTL (6h), NOT 2h** — the 2h TTL
|
||
expired between 5h-apart cron runs and blanked /team + Explore mid-day.
|
||
- **Sport tabs are THE filter** (?sport= URL param, history.replaceState).
|
||
The dashboard has NO second tablist — legacy sections subscribe via
|
||
Slate's `onTabChange`. Don't re-add a duplicate tab row.
|
||
- **`lib/pipelineSchedule.js` mirrors SNAPSHOT_HOURS_UTC** (14,19,22,1,3)
|
||
for "Grades post ~X ET" waiting states; a test cross-checks it against
|
||
snapshotScheduler.HOURS_UTC. Change the cron → change both.
|
||
- **BottomTabBar shows on '/'** — hiding it there left anon phones with
|
||
ZERO navigation (desktop links hide <768, hamburger retired). HIDE_ON is
|
||
auth flows only.
|
||
- **GameCard collapse**: 6 graded props max (sorted A+→F), "ALL N READS →"
|
||
expands in place; GAME LINES <640px = `.gl-summary` best-line row +
|
||
expander revealing `.gl-full.gl-expanded`.
|
||
|
||
## Night-2 Board (Session 60 — non-obvious)
|
||
- **SNAP_TTL is 24h ON PURPOSE** (was 6h): the overnight cron gap is 11h;
|
||
a shorter TTL silently kills the morning accuracy settle. AUTONOMY.md is
|
||
the zero-touch trace — update it when the loop's shape changes.
|
||
- **The snapshot pipeline FEEDS the aggregator**: `mergeRosterLogs` writes
|
||
`rosterlogs:{sport}` (72h TTL, cap 300) from the same stats resolve that
|
||
classifies archetypes. Streaks/hot-lists starve if you remove it — the
|
||
external prefetch (n8n/tank01) was never armed.
|
||
- **THE LENS RULE**: no raw streak renders alone — `streakLens.applyLens`
|
||
attaches built-vs / tonight's matchup (+opposing SP ERA for MLB batters)
|
||
/ difficulty / one-line read. Absent context = say less, never invent.
|
||
Streaks are FREE for every tier; hot lists keep the free top-3 gate.
|
||
- **Phase 2.5 lives in `intradayRefreshService`**: signed delta vs the
|
||
GRADED side; STEAM never re-grades; ≥1.0 against re-grades THAT prop at
|
||
the real current line; a dropped grade sets `revised_from_grade` ONCE
|
||
(the original letter is forever). `applyRevision` only touches unsettled
|
||
public rows. Scheduler skips full-snapshot slots; INTRADAY_REFRESH=0
|
||
kills it. After deploy set SNAPSHOT_EXPECTED_INTERVAL=1200.
|
||
- **/api/players/search is the canonical resolver** (nameKey fuzzy; MLB =
|
||
statsapi list, others = cached names). The Python service is a fallback
|
||
only. Never reintroduce raw-substring matching.
|
||
- **Landing renders instantly for anon**: `hasStoredSession()` sync
|
||
localStorage gate — do NOT re-block the marketing render on
|
||
`useAuth().loading`; that was the 3–4s LOADING THE SLATE bug.
|
||
- **QA.20–22 in vyndrParityQA** lock the night's color semantics (STEAM
|
||
amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and
|
||
schedule-derived waiting copy. Keep them green.
|
||
|
||
## Active Skills
|
||
- vyndr-voice (all user-facing output)
|
||
- prop-analysis (grading methodology)
|
||
- monetization-system (scan-5 pitch, tier conversion)
|