Trace-first: the normalizer functions were correct (S47) but raw names still
flowed through paths that skipped them. Fixed each leaking path.
- 2a (source chokepoint): snapshotService.runSnapshot normalizes each grade's
player to the de-dotted display AND dedupes to one grade per nameKey|stat
(highest confidence) before writing grades:{sport} + snapshot:latest. Every
consumer (GameCard, Explore, leaders, profile) now gets clean merged names.
- 2b: buildPlayerStripsFromProps dedupes a player's props by stat (graded >
awaiting) → one row per stat (kills "Ks 5.5 AND Ks 3.5" variant dupes).
- 2c: scan tonightsPlayers grid groups by nameKey, displays normalized name.
- 3: profile VYNDR INTELLIGENCE "+0%"/"—" was buildIntel's defaults (separate
from the grade card's buildIntelFields, which already works). resolvePlayerStats
now attaches real usage (AB/G) + rest (B2B/Xd); buildIntel renders them; REST
default is now "—".
Backend 2149 -> 2156 tests (+7), 181 suites. Web build clean (exit 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
33 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).
Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
- monetization-system (scan-5 pitch, tier conversion)