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>
16 KiB
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)
- Unit tests pass
- Integration tests pass
- Acceptance criteria met (from spec)
- PR description written
- 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-gamehasOdds/hasGameLinesflags 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 byrosterLogs.js(prefetch blob, else Redis SCAN overgamelogs:{sport}:{player}:{count}). Empty roster = valid empty state.?stat=filters narrow streaks/hotlist; categories inconfig/statFilters.js(mirrorweb/src/config/statFilters.ts). Discovery:/api/stats/filters/:sport.- Dead providers: set
status: 'dead'inconfig/providers.jsto 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 keysPROPLINE_API_KEY_1/2/3, 3,000 req/day FREE, rotates per-key; registryproplinepriority 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 aproviderfield. PropLine is The-Odds-API-compatible → reusesutils/oddsNormalizer. MLB market keys (batter_hits,pitcher_strikeouts, …) were added toMARKET_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). Registrymlb-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 viaanalyzeViaEngine1(engine1 is direction-aware — keeps the higher-confidence side), sorts by confidence desc, writesgrades:{sport}={ grades, updated_at, source }(TTL 2h). The legacy grade shape already matchesnormalizeGrade— 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 byshouldGradeSlate(): ON by default, OFF whenNODE_ENV==='test'(its feature-compute fan-out would pollute call-count assertions), override withGRADE_SLATE_ON_FETCH=1/0.0is 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-infrom opacity .6) so a paused frame is never invisible. - Shared components:
@/components/vyndr/*(Wordmark, GradeBadge, SportBadge, TerminalInput, SectionHead, VBtn, Card, Sparkline, Ticker), helpers inweb/src/lib/vyndrTokens.js. The NEW Wordmark is.wmmarkup at@/components/vyndr/Wordmark; the legacy@/components/Wordmark(.wordmark) is still used by Nav until pages convert.vyndrTokens.jsis 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, andmiddleware.tsis locale-only — a server middleware can't read it. The gate usesuseAuth().loading/user+isGatedRoute()and redirects to/login?next=<path>(the param/loginalready 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.tsxtranslates#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/vyndrWordmark (.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 layoutmainpaddingTop 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 clientNotFoundActionsso 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 vialib/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 castmapScanToGradeResult(...) as GradeResultDataat 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,markReadCompletereads tracking, andnoopener noreferrersportsbook 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 livedashboard/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 LEGACYcomponents/GameCard.tsx(used by the live Slate, with inline grading) was RESKINNED to render its game-lines grid viadetectBestLines(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).
- SportBadge. IMPORTANT: the live Slate still uses the legacy GameCard, NOT
- Real pages (were RouteStubs):
compare,invite,help,about. Only/notificationsis 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).accountredirects 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 viaHIDE_ON, and hidden ≥768px via the.mobile-tab-barrule 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:
mainbottom-padding clears the 64px bar + safe-area <768px;.grade-hero(the GradeResultCard letter) → 80px <640px;.terminal-gridstacks <768px;.game-lines-gridhorizontal-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 conston 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| tailof 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),parlayGradepenalty, 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—applyPrefssets<html data-*>(the S33 CSS layer keys off these), load/save tolocalStorage('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, registerswindow.__prefs/__goPaywall/__checkout, hosts Prefs + Paywall modals).- Header is now 124px tall (nav 60 + ticker 32 + heartbeat 30): layout
mainpaddingTop = 124, Slate stickytop= 122. Keep them in sync if header changes. - GOTCHA: don't return a
Set.delete-based unsub directly fromuseEffect(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)