Files
vyndr/CLAUDE.md
T
builtbykev 1d83682cdb Session 35: Design system Phase D — core screens: Grade Result, Slate card, Scan, Terminal, Landing (1839 tests)
VYNDR 2.0 conversion, Phase D (the screens users touch). Frontend-only; zero
backend changes.

- GradeResultCard + ProcessingGrade (the core product moment): intel-surface
  grade hero, signal breakdown, kill conditions, best-book strip, alt ladder;
  sections self-hide when empty.
- lib/gradeAdapter.js maps engine output -> §7 contract and tier-gates content
  (free teaser / analyst kill-conditions / desk alt ladder) so the new card
  doesn't give paid content away.
- Scan result wired to ProcessingGrade->GradeResultCard, preserving scan limits,
  parlay add, reads tracking, and noopener sportsbook deep-links.
- GameCard (Bloomberg best/worst line cells) built + tested.
- Terminal page replaces its stub with a real league-intelligence screen.
- Landing gets the founder-seat ClaimMeter.

Honest scope: live dashboard/Slate swap onto GameCard, scan input -> TerminalInput,
full landing rebuild, and the blurred-paywall polish (Phase G) are deferred to
keep working flows stable.

22 new tests. Backend 1818 -> 1839, 143 suites, zero regressions. Web build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:20:45 -04:00

228 lines
13 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.
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
- monetization-system (scan-5 pitch, tier conversion)