MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
66 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(); }.
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 deadonClick={}, or break the gated-route list, that suite fails. Keep it green. - INTENTIONAL hex (do NOT "fix" to tokens):
var(--token, #fallback)fallbacks, Next metadatathemeColor, 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:
soccerFeatureExtractorCascadegetsjest.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_typeis gated insrc/routes/analyze.js(/prop+/batch),src/routes/scan.js(parlay legs), ANDsrc/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.jsis 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, NOTrbis/runs_scored/outs_recorded). tests/integration/analyze.test.jssits 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): seetests/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:rootmaps--sans/--mono/--ibm-monoonto them. CRITICAL: next/font OBFUSCATES family names, so literal'JetBrains Mono'/'IBM Plex Mono'in CSS or inlinefontFamilyno longer resolve — always reference the variable. The oldfonts.googleapis.comCDN<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()fromnext/navigation(/settings→/profile,/report→/blog)./settings/securityis 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/profilefetch, 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(ARCHETYPESkeyed by UPPERCASE name; classify/getArchetype). The frontend visual map (color + glyph SVG + desc) is duplicated inweb/src/lib/archetypes.jsbecause 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/GameCardprefersplayerStrips(grouped) over legacy per-prop rows; both kept for back-comat. - Stats API —
src/routes/stats.jsALREADY existed (filters/public/live); Session 42 ADDED/player/:name,/leaders,/game/:id(don't recreate the file). Aggregation logic is insrc/services/playerIntelService.js(testable; injectcacheGet). The name param is sanitized there (strip non-name chars, cap 60). Readsgrades:{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.buildIntelFieldsonly populates them when the engine suppliesarchetype/season_avg/form/etc. They light up once the Session-43 data pipeline feeds them — no empty boxes now. - Settings —
/settingsis 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 ondeleteText === '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>usesbackdrop-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 carriesposition:relative; zIndex:2to float above them — don't remove it, and keep dropdown menus atzIndex:100. - MLB real stats —
mlbStatsAdapteris id-keyed; Session 43 addedsearchPlayer(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.getPlayerIntelclassifies from REAL stats now. Adapters are injectable (opts.mlbAdapter/opts.resolveStats) — tests never hit the network. NBA/WNBA usesnbaStatsClient(Python service, usually offline in prod → degrades tofound: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) andObject.assigns them onto the legacy result.gradeAdaptermaps 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 legacycomponents/GameCard(which ignores those + rendersBookChipin its line grid); the full swap tovyndr/GameCardis still pending (needs inline grading ported).mapPitchersreturns undefined for non-MLB / no probables. - depthChartService (
getLineup/getDepthChart/getCascadeProjection) +/api/stats/lineup|depth|cascadeare the foundation; all graceful + injectable.matchesTeammust guard empty names (t.includes('')is always true) — and the schedulefindchecksg.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 inlib/archetypes.jsare the new names; each carrieslegacyName/legacy(the old label, NEVER displayed).getArchetype()+archetypeInfo()+badgeStyle()resolve legacy names → the canonical VYNDR name (defends stale cached data). Theclassify()scorer objects use the new keys too — if you edit a threshold, use the new key. Full mapping is inBACKEND_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 wasscan/page.tsxpassing a hardcoded field subset tomapScanToGradeResult— it must forward season_avg/last10_avg/form/usage/matchup_grade/rest/archetype /archetype_blend/prop_dna (andScanResponsemust type them) or the card sections stay hidden. - Stale games:
slateAdapter.isRelevantGame(game, now)drops completed games24h old;
Slate.filteredGamesapplies 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) → attachgradedAt {line,odds,timestamp}→computeLineDeltasvs previous → writesnapshot:{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|/allbehindrequireInternalAuth(headerx-internal-key==VYNDR_INTERNAL_KEY)./allis registered BEFORE/:sportor Express captures "all" as a sport. - Cron:
src/snapshotScheduler.js, gatedSNAPSHOT_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, mergesTICKER_MANUALenv 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 viaslateAdapter.buildPlayerStripsFromProps(player name once + archetype + locked grade + line-delta sub-line). Ungraded → "Awaiting next scan", NO Read button.StatStriprenders the gradedAt/delta sub-line when a prop carriesgradedAt/delta/awaiting(snapshot mode), else the inline chip. - Ticker (
vyndr/Ticker) polls/api/tickerevery 30s; the passed items are the initial + graceful fallback (never blanks on fetch failure). - NBA/WNBA stats:
espnStatsAdapteris the FREE fallback when the Python nba_api service is offline.parseAthleteStatsis 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:
buildIntelFieldsreads l5_avg/l20_avg/ opp_rank_stat/rest_days from the feature vector.gameLogService.getGameLogsis NBA/WNBA-ONLY (offline Python service) → MLB props had NO recent/season averages → intel always empty. FIX:featureCache.gameLogFeatureshas an MLB branch usingmlbStatsAdapter.getPlayerStats+ the puremlbGameLogFeatures(MLB stat_type→game-log field viaMLB_LOG_FIELD). If you add an MLB stat type, add it toMLB_LOG_FIELDor its intel won't compute.buildIntelFieldsalso takes{ playerStats, projection }fallbacks — don't remove the resilience. - Player name normalization:
src/utils/playerName.jsis the source of truth (frontend copy atweb/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.sanitizePlayerNamenow returns the de-dotted display ("A.J. Ewing"→"AJ Ewing") — tests that asserted the dotted form were updated. - MLB pitchers: the ESPN
/api/schedulehas NO probable pitchers. They come fromGET /api/schedule/:sport/pitchers(MLB) →probablePitchersservice →mlbStatsAdapter.getScheduleWithPitchers. The Slate matches them to games by team (full name OR mascot viaslateAdapter.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 theNICKNAMEStable ("matt"→"matthew").nameKeydoes the nickname resolution;normalizeNamedoes 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 thatmlbGameLogFeaturesproduced it andbuildIntelFieldshas a branch (usage reads usage_rate→minutes→ab_per_game; matchup reads opp_rank_stat→bvp_advantage). - Ticker SCAN dedup:
pushTickerItemskeeps one SCAN per sport (via thesportfield 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>=15strong /hr>=10moderate). 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.runSnapshotnormalizes every grade's player tonormalizeName().displayAND dedupes to one grade pernameKey|stat_type(highest confidence) before writinggrades:{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);
buildPlayerStripsFromPropsdedupes a player's props by stat (graded > awaiting); scantonightsPlayersgroups bynameKey. If a NEW surface lists players, group bynameKey+ displaynormalizeName().display. - TWO intel renderers — don't confuse them: the GRADE CARD (scan) uses
analyzeViaEngine1.buildIntelFields(features); the PLAYER PROFILE usesplayerIntelService.buildIntel(stats). The "+0%"/"—" bug was the PROFILE's defaults — fixed byresolvePlayerStatsattaching realusage(AB/G) +restandbuildIntelreading them. The grade card already worked (the full feature merge carries ab_per_game/rest_days;FEATURE_NAMESis 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.jsGET/POST behindrequireAuth, read/written via the service client'sauth.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/preferenceson mount and redirects new+incomplete users to/onboarding. Don't rely onsession.user.user_metadatafor 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 whileauthLoading(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 vialib/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.jscollapseInitialsmerges "J C" → "JC" (display + key) so space-separated initials dedupe.
Name Edge Cases + Audit Polish (Session 54 — non-obvious)
normalizeNamenow also strips hyphens to spaces ("Jung-hoo" → "Jung hoo") in display+key;nameKeystrips 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.hasAccentuses 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/:abbrconsumer getsnormalizeName().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).combinedGradepenalizes the leg-grade avg byavgCorrelation*0.5;estimatedPayout= Πfair-odds × (1−avgCorr) discount.gradeParlayis 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/payoutare live on the context — don't call the endpoint from components, read the context. - Leg cap is tier-aware via the context.
maxLegsdefaults 6;ParlayPanelsets it fromuseAuth().tier(free 2 / analyst 4 / desk 6).addLegreads a ref so it stays a stable callback. The "+" buttons no-op at the cap. - "+" wiring:
StatStriptakesonAddLeg/isLegActive;vyndr/GameCardprovides them viauseParlay(it owns the game id + team).legKey=player|stat|line|directionis the dedupe key (exported from the context). - ParlayPanel (mounted in layout, REPLACED
ParlayTraywhich called a then-missing/gradeendpoint). Floating badge bottom-right when closed; free tier blurs the payout with awindow.__goPaywallupsell.
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/teamslist) →getTeamRoster→ per-playergetSeasonAverages(id)(bounded concurrency 8, reusesplayerIntelService._internalsmappers) → archetype (the snapshot grade's locked archetype, elseclassify) → graded props fromgrades:{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 proxyapp/api/team/[abbr].- Page split:
team/[abbr]/page.tsxis a server wrapper (forgenerateMetadata) rendering theTeamHubclient component (sort/filter/parlay are interactive). Player names →playerHref; team abbrs on game cards →vyndr/GameCard'sTeamLink(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=1turns on a per-run[deltas]log incomputeLineDeltas(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 animages: ['/og-image.png']to thelayout.tsxmetadata — that emits a second, conflictingog:imagetag. The legacy static/og-image.pngstill exists inpublic/but is unreferenced. - It uses Node runtime, NOT
runtime = 'edge'— this is a self-hostedoutput: 'standalone'build; edge runtime isn't available off Vercel and breaksnext/oghere. 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: readssnapshot:{sport}:latest, settles each locked grade vs the REAL result frommlbStatsAdapter.getPlayerStats().last10(game-log row whosedatematches the graded date — UTC or ET, to cover late-game rollover), records hit/miss/push, and writesoutcomes:{sport}:log+accuracy:{sport}+accuracy:overall. Idempotent — dedupe key isnameKey|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 staypending(never throw). If you add an MLB stat_type, add it toMLB_LOG_FIELDin 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));samplecounts pushes. Grade buckets:A+stands alone, then first-letter (A-/A→A,B±/B→B). 30-day trailing window keyed off each outcome's gamedate. - The cron settles BEFORE it grades (
snapshotSchedulertick): settle yesterday's now-completed games, then grade today's fresh slate. Trigger manually via internalPOST /api/internal/outcomes/all(same key as snapshot). GET /api/accuracy(public, cached) +GET /api/ledger/accuracy(buckets — the pre-existing Nextledger/accuracyproxy finally has a writer). Browser reaches them viaweb/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). Passsport=for a sport-specific record. Self-hides only when the fetch fails/empty.- Settled outcomes overlay the live slate:
GET /api/snapshot/:sportmergesoutcomes:{sport}:logonto grades (outcome:{result,actual}), threaded throughslateAdapter.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 useslastRefreshed+ a 15snowTick. Ticker has an anchored LIVE badge that flashes on a new head event. - Founder pricing:
stripeServiceFOUNDER_CODE_EXPIRYdefault is now2026-12-31(was2026-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.
- the ClaimMeter pitch). That expiry lapsing — NOT a tier-limit change — was the
cause of the "4 stripe test failures." Operators override via
Data Audit + Pipeline Resilience (Session 56 — non-obvious)
specs/propline-audit.md+specs/vyndr-roadmap.mdare the source of truth for data coverage + the session plan. Re-runnode 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 inoddsNormalizer.MARKET_MAP, whitelisted in all three grade gates (routes/analyze.js,routes/scan.js,python/utils/validation.py), AND (for MLB) present inMLB_LOG_FIELDin BOTHfeatureCacheandoutcomeService. Miss the map → silent zero; miss the log field → no features + no settlement.batter_rbiswas mapped torbiswhile everything else keyed onrbi— a 4-layer desync that silently killed RBI props. It'srbinow; a normalizer test locks it. - The streaks/hotlist path uses its own
rbiskey 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 →
accuracyreflects MLB only). Fixing that (ESPN box-score settle path) is roadmap Session 57. src/utils/opsNotify.jspushes pipeline alerts to ntfy (vyndr-pipeline- kev2026). It NEVER throws and is auto-disabled underNODE_ENV==='test'/PIPELINE_ALERTS=0(injectfetchImplto test it).snapshotServicealerts on success/stale/failure;snapshotSchedulerhas a per-minute missed-cron watchdog (isSnapshotOverdue, exposed asoverdueon 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=YYYYMMDDAND 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. liveTickcarries NO display data — state is{ tick }only (1s re-render pulse). Real header numbers come fromGET /api/snapshot/summary(public, cache-only; registered BEFORE/:sportinroutes/snapshot.js).vyndrSystems.test.jsfails 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.tsxas §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) === 0is the classic fabrication bug — usenumOrNull(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 useignoreDuplicates: trueso 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 →
snapshotServicecallsledgerService.recordPipelineGrades+captureClosing(best-effort, never breaks the snapshot; no-op without SUPABASE env). User scans → the NEXT/api/scanroute (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):
analyzeViaEngine1REFUSES (grade null +insufficient_data: true) whenprojectionForfinds 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:
getModelAggregatereturnshit_pct/beat_close_pctas 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/summaryexpected_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.searchPlayerresolves vianameKey(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 (snapshotteam, 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.jsmirrors 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-summarybest-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:
mergeRosterLogswritesrosterlogs:{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.applyLensattaches 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 setsrevised_from_gradeONCE (the original letter is forever).applyRevisiononly 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 onuseAuth().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.
Affiliate + Partner Plumbing (A1 S3 — non-obvious)
- Every sportsbook link goes through
web/src/lib/bookLinks.js(buildBookLink→{url, tracking}; unknown book → null). The affiliate layer isweb/src/lib/affiliateConfig.js— ALL booksenabled:false(no program approved); flipping a book + filling params produces tracked URLs with NO component changes. Empty param values are SKIPPED (a half-filled config degrades to organic, never a fabricated id). Anchors MUST renderrel={BOOK_LINK_REL}("sponsored noopener noreferrer") — tests assert it. - Best-price dot honesty rule:
slateAdapter.detectBestBook(rows, side, refLine)returns a book ONLY when ≥2 books post the SAME line for the side and prices differ — never compare odds across different lines, never mark a lone price "best". The per-book rows reach the browser aslines[]on each grouped prop;Slate.groupByGamenow threads them asbookson PropRowProp (pickLine still picks the single displayed line). - Partner attribution:
?ref=CODE→vyndr_refcookie (90d, FIRST-touch,lib/partnerRef.js+PartnerRefCapturein layout) →signUpmetadatapartner_ref→ internalGET /api/partners/report/:code. Theuser_profiles.partner_refcolumn does NOT exist yet — the endpoint returns zeros + note until the TODO migration indocs/PARTNERS.mdruns (metadata lands in auth.users.raw_user_meta_data, which PostgREST can't query). OAuth signups carry no metadata (known gap). Partner code == Stripe promotion code, verbatim (docs/PARTNERS.md §3). - Worktree build gotcha:
web/node_modulesisn't shared into git worktrees and Turbopack REJECTS a symlink pointing outside the project root — usecp -al(hardlink copy) from the main repo's web/node_modules.
Newsletter — THE VYNDR REPORT (Session S7, a1 — non-obvious)
newsletterServiceis the whole layer:buildDailyReport(sports, deps)(deterministic VOICE v1.1 template over snapshot grades + streak lens +getModelAggregate; every dep injectable),subscribe(email)andsendDailyReport(opts)(Listmonk, self-hosted, zero out-of-pocket). ALL Listmonk paths are env-gated onLISTMONK_URL/USER/TOKEN/LIST_ID— any missing →{ ok:false, reason:'not configured' }, and the capture UI shows "Signups open soon". Auth is the API-user schemeAuthorization: token user:token, NOT basic auth.- Double opt-in is Listmonk's job: subscribe sends
preconfirm_subscriptions:false; the list itself must be created as double-opt-in (runbook:docs/NEWSLETTER.md). The email template embeds the LITERAL{{ UnsubscribeURL }}— Listmonk substitutes per-recipient; never "fix" it. A 409 (already subscribed) is returned as ok — no enumeration. - The send is deliberately unscheduled. Only
POST /api/internal/newsletter/send(internal key) triggers it; an empty report (0 signals AND 0 streaks) refuses to send. The record line renders a percentage ONLY whenhit_pct != null(n≥20 gate upstream ingetModelAggregate), else "RECORD BUILDING · N pending". - VOICE lint is executable:
tests/unit/newsletterService.test.jsfails on any!, banned vocabulary (word-boundary regex), or hype emoji in subject/html/text. The RG disclaimer says "No outcome is promised" — the word "guarantee" trips the lint even in a disclaimer, keep it out. - Capture placement: the signup "done" card auto-redirects after 1.5s
(
setTimeout(router.replace)) — useless for a form. The free-signup success surface is/welcome. Landing capture sits below FAQ. Subscribe route is 10/min IP-limited → keeptests/integration/newsletterRoute.test.jsunder 10 subscribe requests (same lesson as analyze.test.js).
Ops Self-Watch (Session 8, A1 board — non-obvious)
- Pure alarm logic lives in
src/services/opsWatch.js(failure tracker, zero-settle signal, quota daily check, pulse assembly) +src/services/ systemHealth.js(statfs/os, injectable).snapshotScheduleronly WIRES them — keep new alarm logic in opsWatch so it stays unit-testable. - Zero-settle signal = the ledger settle's own return values (Postgres
game_date < today AND outcome IS NULL), NOTsnapshot:{sport}:previous— the snapshot key expires in exactly the failure mode the alarm exists to catch (the S60 SNAP_TTL bug). Scoped toSETTLEABLE_SPORTS(mlb); NBA/WNBA rows legitimately stay pending and must not page daily. Morning slot only (morningHourUtc= first configured hour ≥ 06 UTC). - Failure pager pages ONCE per losing streak (exactly at count 3), not
once per failing slot;
status:'skipped', reason:'no grades'is NOT a failure (props arrived, grader refused) and resets the counter. - Daily pulse fires at
PULSE_HOUR_UTC(default 13 = 9 AM EDT), one notification, dual dedupe (in-process date + Redisops:pulse:{date}). Missing data renders 'n/a' — a pulse never fabricates a zero (Data Semantics Rule applies to ops copy). All alert copy: no exclamation points, ever (VOICE v1.1) — tests lint for it. - Single-suite jest runs of scheduler tests can hang at exit when redis is down (ioredis reconnect timer keeps the process alive) — PRE-EXISTING at baseline, full-suite runs are unaffected. Don't chase it as a leak in new code; inject cacheGet/cacheSet in tests to avoid creating a client.
- Box-side ops (Uptime Kuma, Coolify deploy-failure webhook, ntfy phone
setup) =
docs/OPS-RUNBOOK.md.
Slip Reader (Session 9, A1 board — non-obvious)
src/services/slipReader.js— PURE per-book OCR-text layout parsers (DK/FD/MGM/Caesars) +detectBook+parseSlipText(text, bookHint). The NEVER-GUESS contract: every field carries a confidence; belowCONFIDENCE_THRESHOLD(0.6) the field is NULL + the legneeds_review:true. Values are USER-SLIP values (source:'user_slip') — never written to any market cache. Stat labels normalize viaSTAT_ALIASES→ the scan-route vocabulary (a unit test cross-checks every alias against VALID_STAT_TYPES); players viaplayerNamenormalizeName/nameKey.- Caesars head split is alias-anchored: "Pete Alonso Home Runs Over 0.5"
is regex-ambiguous (greedy name eats "Home" → stat "runs" = WRONG-stat
fabrication).
splitPlayerStatmatches the LONGEST known stat alias the head ENDS with; no alias → stat null AND player low-confidence (boundary unknowable). BetMGM legs carry "@ -115" — don't pre-filter its lines with the game-row@heuristic; the Over/Under grammar is the filter. - OCR = tesseract.js v7 (Apache-2.0), lazy-required in
recognizeImageso the parsers load without WASM. ~46MB on disk (core WASM); eng traineddata (~11MB) downloads ONCE at first recognize and caches atTESSERACT_CACHE_PATH(default.tesseract-cache/) — first prod OCR call needs outbound network, then it's fully offline. POST /api/slips/parse(requireAuth): base64/data-URL image ≤4MB decoded OR rawtext(paste path, also what hermetic route tests use). Daily quotaslips:{user}:{YYYY-MM-DD}— free 1/day, paid 10/day; Redis counter with in-memory mirror (max of both enforces). Validation runs BEFORE the quota is burned. Injectable viarouter.__internals.setDeps. Next proxy:web/src/app/api/slips/parse/route.ts(S25 rule)./slippage (added to GATED_ROUTES): upload/paste → editable leg rows (nulled fields amber; editing clears the flag) → each leg graded via the EXISTINGPOST /api/scan(refusals render "NO GRADE — insufficient data") →useParlay().addLegper graded leg → share card is copy-text (no OG plumbing). Sport selector is MLB/NBA/WNBA (ParlayLeg's sport union).- Vision-model upgrade is POST-REVENUE (specs/vyndr-roadmap.md) — the route contract is the stable interface; only the extraction engine swaps.
Public Ledger Profiles v1 (Session 10, A1 board — non-obvious)
public_profiles(migration 022) is PRIVATE BY DEFAULT —publishedDEFAULT FALSE, one explicit toggle in Settings, service-role writes only.GET /api/profiles/:handlereturns the SAME 404 body for unknown AND unpublished handles (no existence leak) — keep them byte-identical; a test diffs the two responses. A malformed handle 404s WITHOUT a DB query.getModelAggregate({ userId })swaps.is('user_id', null)for.eq('user_id', uid)on BOTH the settled and pending queries — the same 30d window + n≥20 gate over one user's ledger. The no-userId default is the public model record and must stay untouched.- The user's public entries are SETTLED rows only (
.not('outcome', 'is', null), newest 50, same columns as /api/ledger) — pending reads are not public until they settle, and nothing is curated: misses included. /uis in OPEN_ROUTES on purpose — the share surface must load anonymous; the privacy gate is the API's 404, never the router.- Jest from a worktree gotcha: the repo config's
testPathIgnorePatternsincludes/.claude/, which matches EVERY path inside.claude/worktrees/...→ "No tests found". Run withnpx jest --testPathIgnorePatterns "/node_modules/"from a worktree (CLI replaces the config array).
Row Grammar + Display Layer (Session S6, A1 board — non-obvious)
specs/ROW-GRAMMAR.mdis LAW, locked bytests/unit/rowGrammar.test.js(source-order assertions on StatStrip). Prop-row slot order: identity → viability → stat+line → market context (best dot, movement chip) → model output (revision strike, grade) → outcome → actions → provenance. If you reorder a StatStrip element, amend the spec + test in the same commit or the suite fails. Red = settled-negative ONLY (miss/dead/stale/faded); the line sparkline is green(toward)/amber(against)/dim(flat), never red.- Sparkline history is captured by
intradayRefreshService.trackHistory— {t, line} on each grade, seeded with the LOCKED line, deduped when flat (a point means the line MOVED), capped 24, persisted inside the snapshot write-back (no new Redis keys).StatStrip.LineSparklineneeds ≥3 points.Number(null) === 0struck again here — all three S6 numeric paths (trackHistory, last10Dots line, clvBucketIndex) use strict null guards. last10_dotsis attached at READ time inroutes/snapshot.jsfromrosterlogs:{sport}viaservices/last10Dots(reuses streaksService accessors — new MLB accessors runs/doubles/triples/outs live THERE, one source of truth). Dots score vs the LOCKED line (gradedAt.line), not the current line.clv_distributionlives ingetModelAggregatebehind the SAME n≥20 gate as hit_pct (null below) — never re-derive the gate in a consumer. Buckets clamp outliers into the edges ([-2,-1) … (1,2]).- Global search —
window.__search+ ⌘K registered in GlobalHosts; SearchModal fans out to/api/players/searchper sport (MLB/NBA/WNBA) + staticweb/src/lib/teams.js(soccer deliberately ABSENT — no canonical registry; add it with the soccer team hub, not before). BottomTabBar keeps the Explore tab; Search is the first More-sheet item. - LCP mechanics:
fade-upnow floors at opacity .6 — a 0-start entrance animation makes the hero h1 a LATE LCP candidate (invisible content isn't contentful). IBM Plex Mono ispreload:false(decorative face; its 4 weight files competed with Inter + CSS on slow 4G). Measure before/after via PageSpeed post-deploy — not runnable from the box.
Live Tracking (Session S11, A1 board — non-obvious)
- GRADES NEVER CHANGE IN-GAME.
liveTrackingService+/api/live/:sportproduce per-player CURRENT box values;web/src/lib/liveProgress.jsturns them into PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome slot (StatStrip.LiveTracker). Spec =specs/LIVE-TRACKING.md; slot-6 + color amendments are locked inrowGrammar.test.js— an in-progress prop is NEVER red (green = hit/on-pace/holding, amber = needs/line-passed). - THREE MLB stat maps now exist on purpose — do NOT merge them:
featureCache.MLB_LOG_FIELD(features),outcomeService.MLB_LOG_FIELD(settlement, game-log rows pick ONE group by position), andliveTrackingService.LIVE_BOX_FIELD(live boxscore carries BOTH stats.batting AND stats.pitching per player → each stat names its group, and innings_pitched parses in THIRDS: '5.2' = 5⅔ — parseFloat is wrong). Adding an MLB stat_type = wire all three. - Absent beats zero, live edition: a player not yet in the game has
EMPTY
stats.batting/stats.pitchingobjects (MLB) ordidNotPlay/empty stats row (ESPN WNBA) → he is OMITTED from the live index. Never coerce to 0. - Quota shape:
live:{sport}:{date}TTL 90s is the shared window — 1 schedule (hydrate=linescore, so inning progress costs no extra call) + 1 boxscore per live game per 90s TOTAL across all users; zero boxscore calls when nothing is live. The Slate polls/api/live/{sport}every 60s ONLY while live mlb/wnba games are on screen. The route never fetches for unwired sports (short-circuits before the service). - The strip join needs
statType—buildPlayerStripsFromPropsnow puts the canonical lowercase stat key on every strip prop (statis the short display label like 'TB' and can't be joined on). attachLiveProgress only touches graded, unsettled, non-dead props, and computes state vs the LOCKED line (gradedAt.line). Under semantics are HOLDS-IF: an under is never 'hit' until the settle pass; exceeded → amber LINE PASSED, not red. - Actions are suppressed while live (
!p.liveon ParlayBtn/BookIt — the pre-game market for a locked line closed at first pitch); the label "TRACKING — READ LOCKED PRE-GAME" renders once per live card (GameCard, dim — it's meta, not a caution signal).
Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
- monetization-system (scan-5 pitch, tier conversion)