tesseract.js (self-hosted WASM, Apache-2.0) + pure per-book layout parsers (DK/FD/MGM/Caesars) with per-field confidence and needs_review honesty — the reader never guesses. POST /api/slips/parse (auth, free 1/day paid 10/day, 4MB cap) + Next proxy. Gated /slip page: upload or paste, manual-correct UI, per-leg grades through the normal engine (refusals render honestly), add-all to Parlay Lab, share card. Vision model upgrade logged post-revenue. 2574 -> 2608 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
60 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.
Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
- monetization-system (scan-5 pitch, tier conversion)