e453c24d2c
Final phase of the VYNDR 2.0 conversion (Sessions 33-39). Verify -> fix -> lock. Frontend-only; zero backend changes. §13 automated checklist: all PASS or FIXED. - QA.1 token resolution FIXED: ProcessingGrade #00ffb8 -> var(--g-ap); game/[id] sport literals -> var(--s-*). Remaining hex documented as intentional (var-with-fallback, Next metadata, bespoke intel-surface shades). - QA.6 glitch discipline: ZERO glitch on data components. - QA.4/5/8/11/16/18 verified; QA.9 (cmd palette) documented deferred; QA.17 (AI slop) flagged for Kev's manual browser review. De-flake: soccerFeatureExtractorCascade hit Jest's 5s default under full-suite load (falls through to live adapters on cache miss) -> jest.setTimeout(20000), same family as the S32 pipeline test. Verified stable across 3 full-suite runs. New: tests/unit/vyndrParityQA.test.js (17 tests locking the parity invariants). Backend 1890 -> 1907, 146 suites, zero regressions (stable x3). Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
300 lines
18 KiB
Markdown
Executable File
300 lines
18 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).
|
||
|
||
## Active Skills
|
||
- vyndr-voice (all user-facing output)
|
||
- prop-analysis (grading methodology)
|
||
- monetization-system (scan-5 pitch, tier conversion)
|