956a7455eb
VYNDR 2.0 conversion, Phase G (the systems that make the design alive). All 5 wired. Frontend-only; zero backend changes. - lib/parlayMath.js: correlation model (0.62/0.34/0.06/0) + parlayGrade penalty + grade->odds + combined odds (frontend; backend parlayService unchanged). - lib/oddsFormat.js: fmtOdds across american/decimal/fractional/implied with the totals-pass-through rule (safer than the prototype's parseAm, which would mis-convert 228.5) + region presets. - lib/prefs.js: applyPrefs sets <html data-*> (the S33 a11y CSS layer) + load/save. - lib/liveTick.js: single tick engine (SSR/test-safe, no auto-start, fresh state). - lib/checkout.js: checkoutUrl(plan). - LiveLayer (useLive/LiveNumber/HeartbeatBar) under the Nav ticker; GlobalHosts in layout applies prefs + registers __prefs/__goPaywall/__checkout + hosts the Preferences and Paywall modals. Nav read-meter is now a paywall trigger. Gotchas: useEffect can't return a Set.delete unsub directly (boolean != cleanup); header grew to 124px so layout paddingTop + Slate sticky-top updated to match. 18 new tests. Backend 1872 -> 1890, 146 suites, zero regressions. Web build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
16 KiB
Markdown
Executable File
283 lines
16 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(); }`.
|
|
|
|
## Active Skills
|
|
- vyndr-voice (all user-facing output)
|
|
- prop-analysis (grading methodology)
|
|
- monetization-system (scan-5 pitch, tier conversion)
|