# VYNDR β€” WIRING & DATA TRAIN Β· STEP 0 MAP ### Branch `wiring/data-train`. The map before the build. Governing law: the $1M/mo feeling is PRODUCED, never claimed. Honesty guardrail: real asset where genuinely sourceable; honest fallback (monogram/text/absent) where not. Never fabricate or scrape a real person's face/data to fill a hole. Legend: **βœ… wire-able now** (data already in the system) Β· **πŸ”§ sourceable free** (real source exists, needs a new adapter, zero-out-of-pocket) Β· **β›” blocked** (no honest source today β†’ keep fallback) Β· **πŸ†• net-new**. --- ## STEP 1 β€” TWO TRUST BUGS ### 1a. Billing "RENEWS 6/9/2036" β€” βœ… fixable - **Render:** `web/src/app/profile/page.tsx:128` renders `subscription_end` verbatim from the Supabase `user_profiles` row. Honest render, bad data β€” no hardcoded 2036 in source. - **Root cause (UPDATED 2026-07-27 β€” NexaPay purged):** VYNDR is Stripe-only. The old note pointed at a `web/src/app/api/webhook/nexapay/route.ts` that wrote `subscription_end = now + 30 days` β€” that webhook was cross-project contamination and has been DELETED. The 2036 value is a **manually-seeded/comped founder-account row** the UI trusts blindly. The *real* Stripe `current_period_end` exists at `GET /api/stripe/status` (`stripeService.js:278`) but the UI never consumes it. - **Task:** point the "Renews" stat at the Stripe provider-asserted value (consume `/api/stripe/status.current_period_end`); correct/clear the stale 2036 row. Guard: never render a `subscription_end` the provider didn't assert β†’ fall back to `β€”`. ### 1b. James Wood β†’ "Chicago Cubs" / builds vs AL East β€” βœ… fixable (nameKey collision) - **Root cause:** `mlbStatsAdapter.js:192` `people.find(p => nameKey(p.fullName) === targetKey)` returns the **first** exact-nameKey match with **no namesake disambiguation**. A second "James Wood" in the statsapi season list (a Cubs-affiliate namesake) wins β†’ wrong `currentTeam`. The S59 substring guard only covered *fuzzy* mismatches; it never covered two players sharing an exact name β€” **not a regression**, an uncovered case. - **Propagation:** wrong team enters `snapshotService.js:315` `teamByPlayer` + rosterlogs opponents β†’ `streaksService.js:229` `team` + opponents β†’ `streakLens.js:79-82` "built vs" / tonight's-opponent. The S59 slate JOIN INVARIANT guards only Slate cards; the **streaks/rosterlogs path bypasses it**. - **Task:** add an optional `teamHint` to `searchPlayer`/`getPlayerStats`/`resolveStats`; on multiple exact-nameKey matches pick the one whose `currentTeam` matches the hint, else return null (never guess β€” S59 doctrine). In `snapshotService.js:311` pass the prop's game team as the hint and drop `stats.team`/rawLog if it disagrees with the prop's game participants. - **Test:** join-invariant β€” a resolved player's `team` must equal / be a participant of the prop's game; a rosterlogs entry's `team` must match the graded prop's team β†’ omit on mismatch, never tag a foreign team. --- ## STEP 2 β€” ENTITY LAYER, FULLY FED **Root-cause verdict:** team **logos already render live**. Player **headshots break at a data-threading gap, not a helper bug** β€” the MLBAM id is fetched then severed before it reaches the browser. | Surface | Asset | Status | Task | |---|---|---|---| | Game-card team logos | ESPN logo CDN | βœ… live (`vyndr/GameCard.tsx:121` `TeamLogo`) | none; add any missing abbrs to `teamMeta.js` | | MLB slate headshots | `img.mlbstatic.com/…/people/{id}/headshot` | βœ… id fetched (`playerIntelService.js:141`) but **severed at `snapshotService.js:331-336`** | thread `playerId` β†’ enriched grade β†’ `slateAdapter.buildPlayerStripsFromProps` β†’ `StatStrip.tsx:381` `playerId={ps.playerId}` | | MLB scan-search headshots | same | βœ… id already on `/api/players/search` `p.id` | pass `playerId:p.id` at `scan/page.tsx:566` (+ `SearchModal.tsx`) | | MLB hot-list headshots | same | βœ… id via `rosterlogs` | `HotListPanel.tsx:68` uses raw silhouette `` β†’ swap for `PlayerAvatar` (branded monogram on null) | | NBA/WNBA headshots | cdn.nba.com / ESPN athlete id | β›” **no real id in data** (search returns synthetic `sport-i-key`; `resolvePlayerStats` NBA branch emits none) | source a real id first, or keep honest monogram. NBA/WNBA don't settle yet β†’ low ROI now | | Soccer headshots | β€” | β›” no central CDN (by design) | honest monogram | | Ledger book wordmarks | `BookWordmark` (brand-color text) | βœ… built but **never imported**; renders raw lowercase `row.book` at `ledger/page.tsx:368` | import `BookWordmark`; add 6 missing keys to `books.js` (`fanatics/bet365/hardrockbet/betrivers/pointsbet/pinnacle` fall to gray) | - **Sportsbook wordmarks:** the work order asks for **local SVGs**. Current `BookWordmark` is brand-color *text*. Decision below (D3) β€” text-wordmark ships today; SVGs are an upgrade. - **Headshot normalization:** `PlayerAvatar` already enforces size/round treatment; extend to guarantee consistent crop when a real photo loads. `onError`β†’monogram is first-class (no broken-image icon ever). **Wire-able now: MLB headshots (3 surfaces) + ledger wordmarks. Blocked: NBA/WNBA headshots (no id source).** --- ## STEP 3 β€” OUTLOOK MODE (never-empty slate) β€” βœ… wire-able now - DS2's `heroFallbackState`/`buildHeroReceipts` (`slateAdapter.js:467-495`) covers ONLY the "Top grades tonight" hero (`dashboard/page.tsx:314-418`) β€” never blank there. **Gap:** the `` game grid (`Slate.tsx:1001-1013` `emptyStateCopy`) AND the dashboard's separate "Today's games" section (`page.tsx:421-439`) each independently fall to a dead-end "NO SLATE" card. - The `"No games available…"` string at `Slate.tsx:609` is the **network `fetchError`** state, distinct from empty-slate. - **Fill data already fetched:** yesterday's settled receipts (`/api/ledger/model`, already loaded), tomorrow's date-pinned schedule (`scheduleService`), ticker items, month-aware `emptyStateCopy`. Only the wiring into the grid branch is new. - **Task:** extend the DS2 fallback below the hero β€” when `games.length===0`, render receipts / tomorrow's schedule preview inside the grid slot in both `Slate.tsx:1001` and `dashboard/page.tsx:424`, not a CTA card. --- ## STEP 4 β€” MISSING SURFACES | Surface | Status | Verdict | |---|---|---| | **Live grade-shift timeline** | βœ… wire-able now | backend done (`intradayRefreshService` revisions + `revised_from_grade` + line history); reaches UI as per-row chip (`StatStrip.tsx:171`) + `LineSparkline`. Missing only a **timeline VIEW** assembled from already-emitted movement/history data. No new backend. | | **Market-breadth / consensus-vs-model** | βœ… wire-able now | per-prop `books[]` (`Slate.tsx:373`) + `model_value` + snapshot line-deltas already present. Missing only a component that computes median-book-vs-model. `DeskShowcase.tsx:89` currently *advertises* it as copy β€” must become real or the claim goes. | | **Parlay Lab builder** | βœ… math done, πŸ†• UI | `parlayService`/`/api/parlay/grade`/`ParlayContext` all live; combined grade + correlation + payout render in `ParlayPanel`. "Empty tray" because the **only leg source is the "+" on live slate rows** and there's **no `/parlay` page**. Task: build a Parlay Lab page with a prop search/browse leg source independent of the slate. | | **Pitcher arsenal** (mix/velo/usage/whiff%) | πŸ”§ sourceable free / πŸ†• feed | **NOT in statsapi** β€” this is Statcast / Baseball Savant (free, public). Needs a genuinely new Savant adapter, not wiring. | | **/u public profile** | βœ… route live, β›” no record | route + aggregate machinery work; `/u/vyndr` 200s to an honest not-found because **no `public_profiles` row is claimed+published** AND `getModelAggregate({userId})` needs **β‰₯20 settled rows** under that user. Structural note: it scopes to a *user's own* ledger β€” the **house model record is `user_id=NULL`** and isn't surfaceable as a user profile without a "house profile" option. | **Wire-able now:** grade-shift timeline, consensus strip, Parlay Lab UI. **New free feed:** pitcher arsenal (Savant). **Data-gated:** /u real record (needs a published house profile β€” see D2). --- ## STEP 5 β€” COMBAT (net-new, largest lift, sequenced last) - **Verdict: genuinely net-new.** Only inert placeholders exist (`sports.js` `mma:{active:false,comingSoon}`, a share-card color, a dormant ESPN URL in `ESPNAdapter.js`, Pinnacle id 22). No pipeline/route/service/archetype/grade path. **No combat spec exists in the repo** β€” it must be authored as Step 5's first artifact. - **PropLine has no combat** β†’ combat cannot ride the abundant-props path; it's **odds-api-only**, which changes the quota story. - **Free source option space (report-only, no pick):** - **ESPN MMA** (`site.api.espn.com/…/mma/ufc/scoreboard|summary`) β€” free JSON, no auth, **same family VYNDR already uses**; fight cards/results/method/round + some bio. Thinner stat depth than ufcstats. The URL already sits dormant in `ESPNAdapter.js`. - **ufcstats.com** β€” richest tale-of-the-tape + striking/grappling granularity, but **HTML scraping β†’ needs a parser dep (cheerio) + fragile**; UFC-only. - **The Odds API `mma_mixed_martial_arts`** β€” **already paid** (`ODDS_API_KEY`); realistically **moneyline + round totals** only. Method-of-victory/round/props are **thin-to-absent** on the standard feed β†’ data-constrained, not just build-constrained. - Wikipedia/Wikidata β€” bio backstop only. - **Honesty caveats:** never ingest fighter photos (likeness/rights β€” same rule as headshots); scraping must degrade to *absent*, never fabricated 0; if only ML+round-total are free, the UI must not imply a full method/prop board. - **Biggest architectural departure:** combat's unit is a **matchup, not a per-player prop** β†’ the current `analyzeViaEngine1` per-prop feature-vector doesn't fit; a **style-matchup grade engine** is closer to a new engine than a config add. Settlement needs a new ESPN-MMA result path (like the pending WNBA box-score work). - **Cheapest honest v1:** ESPN-MMA (free) + odds-api ML/round-totals (paid, existing) + a net-new style-blend archetype set + a matchup-grade path; method/round/props flagged data-limited. --- ## FOUNDER DECISIONS (LOCKED β€” 2026-07-13) - **Global visual reference:** `specs/design-reference/vyndr-system.html` (the Claude Design "Vyndr System" mockup, + `support.js`). Build EVERY surface toward it β€” layouts, the missing surfaces, color treatments, component designs. Mockup surfaces confirmed present: edge board, hero grade reveal, VYNDR intelligence panel, player identity block, archetype glyphs (**74 marks Β· 8 sports** β€” an expansion target), pitcher identity (arsenal/whiff/velo), correlation builder + parlay slip, live grade-shift (grade-by-game-clock, historyΒ·last-24), public record /u (CLV-verified, portrait 1080Γ—1350 + OG 1200Γ—630), fighter tale-of-the-tape (FIGHTER A/VERDICT/FIGHTER B, moneyline/KO/decision, style archetypes), pricing tier, empty/error 404-bar. **ONE exception: keep the CURRENT live-site VYNDR wordmark/name, NOT the mockup's.** Live wordmark wins; everything else references the mockup. - **D1 β€” OVERRIDE β†’ sport-agnostic entity layer NOW.** Don't defer NBA/WNBA to monograms. Capture a stable athlete id per player **at ingestion for every league** (ESPN cross-sport athlete id is the likely single key covering NBA/WNBA/NFL/NHL/soccer; MLB keeps its MLBAM thread-through). ONE headshot resolver keyed by `(league, id)`. Monograms remain the honest fallback ONLY where an id genuinely can't resolve. If a league needs a new ingestion step for the id, build it. Report any league with NO free id source. - **D2 β€” default:** /u house-mode reading the `user_id=NULL` public aggregate. - **D3 β€” OVERRIDE β†’ bundle the ~8 real book SVGs in the SAME wave (Wave 2).** Text wordmarks are only the stopgap; the SVGs are the real fix and are cheap. - **D4 β€” default:** Baseball Savant adapter for pitcher arsenal (free). - **D5 β€” default:** author the combat spec + ship the honest free v1 (ESPN-MMA + odds-api ML/round-totals; method/round/props flagged data-limited); defer the full matchup-grade engine to its own sub-wave; confirm before any scraping dep. ### ADDITION 1 β€” ASSET STORAGE (ingestion-time resolve) Resolve idβ†’URL **once at ingestion**; store the id + resolved-or-404 status in our data so we don't re-check per render. Browser caches the actual image via CDN cache headers (no per-load re-fetch, no per-render re-resolve). Self-hosting/proxying images is a LATER optimization β€” not now. ### ADDITION 2 β€” RECORD BY GRADE TIER (non-negotiable, core not cosmetic) The record must be tracked + displayed **PER GRADE TIER** (A+ went X-Y, A X-Y, B X-Y, C X-Y β€” each its own hit-rate), everywhere the record shows (dashboard, /u, ledger). A single blended "67%" hides the proof: higher grade wins more often β€” the tier calibration IS the credibility. Sport/bet-type slices are a plus; per-tier is required. Extend `ledgerService.getModelAggregate` (and any schema/query it needs) to return per-tier buckets; render them on every record surface. `A+` stands alone; then A/B/C/D/F by first letter (mirror the existing `outcomeService` bucketing). ### ADDITION 3 β€” DESIGN FILE IS THE GLOBAL REFERENCE Weave in essentially everything from the mockup across all surfaces (see reference note above). Wordmark is the sole exception. ## ORIGINAL DECISION DEFAULTS (superseded where noted above) - **D1 β€” NBA/WNBA headshots:** no real id source in the data today, and those sports don't settle yet. **Default: ship MLB headshots now; keep honest branded monograms for NBA/WNBA until an id source is added.** (Alt: invest in sourcing cdn.nba.com/ESPN ids now.) - **D2 β€” /u real record:** the CLV-verified profile can't show a house record as a *user* profile (house = `user_id=NULL`). **Default: add a "house/model" profile mode that reads the public `user_id=NULL` aggregate** (the partner-pitch weapon shows the real 55-19), keeping user profiles gated at nβ‰₯20. (Alt: claim+publish a house account and let it accrue 20 settled reads β€” slower, but no code path added.) - **D3 β€” Book wordmarks:** **Default: ship the existing brand-color text `BookWordmark` now (kills lowercase "betmgm"), bundle local SVGs as a follow-up wave** (SVGs are the work order's ask but a bigger drop-in). (Alt: source + bundle the ~8 SVGs before shipping Step 2.) - **D4 β€” Pitcher arsenal:** Baseball Savant is free β†’ **Default: build the Savant adapter (zero-out-of-pocket).** Flag any field Savant doesn't cover as absent. - **D5 β€” Combat scope:** **Default: author the combat spec + ship the free honest v1 (ESPN-MMA + odds-api ML/round-totals), flagging method/round/props as data-limited; defer the full matchup-grade engine to its own sub-wave.** Confirm before adding any scraping dep (ufcstats/cheerio). --- ## WAVE 2 BUILD PLAN (LOCKED β€” de-risk findings) The `(league,id)`β†’URL resolver (`web/src/lib/playerHeadshot.ts` `getHeadshotUrl`) + `PlayerAvatar` (headshot-or-monogram via ``) **already exist and are correct**. PropLine props carry NO id β†’ the id can ONLY come from the **stats resolve the snapshot already runs per player** (`snapshotService.js:311`) = Addition 1's intent exactly. **Store `playerId`+`espnId` on the `enriched` grade objects at `snapshotService.js:331-336`** (beside `archetype`/`team`) β†’ flows free to `grades:{sport}` β†’ `buildPlayerStripsFromProps` β†’ `StatStrip` `playerId`. Zero new Redis key, zero new I/O. | League | Verdict | Wiring | |---|---|---| | MLB | βœ… now | MLBAM `res.id` already captured (into rosterlogs only) β€” add to `enriched`; thread to strip/scan/hotlist | | NBA/WNBA | πŸ”§ free now | `espnStatsAdapter.getSeasonAverages` **resolves the ESPN athlete id at `:89` and discards it** β€” return it as `espnId`; surface via `resolvePlayerStats` nba/wnba branch; thread `espnId`β†’`getHeadshotUrl`; runtime-verify ESPN coverage | | NFL/NHL | dormant | not in `ACTIVE_SPORTS` (+ off-season); add `nfl`/`nhl` to `ESPN_SPORT_PATH` (`playerHeadshot.ts:39`); inherits ESPN id when ingested | | Soccer | β›” blocked | no live resolve branch; API-Football has `player.id` + `media.api-sports.io` CDN but is key-gated (`API_FOOTBALL_KEY`) + unwired β†’ **honest monogram** until a soccer resolve branch + key land. **This is the one league to report as no-free-id-today.** | **404-status (Addition 1):** id resolve is free; a true resolved-or-404 status needs an image HEAD probe (extra I/O). `` already degrades per-render + the browser CDN-caches β†’ **ship id-only first**; add a bounded HEAD probe in the `mapLimit` loop only if the one-frame monogramβ†’photo flash is objectionable. Book SVGs (D3) + ledger `BookWordmark` import ride this wave too. ## WAVE SEQUENCE (ship on green, per step; toward the mockup) 1. **Wave 1 β€” Step 1 trust bugs** (billing renewal date + Wood nameKey-collision + streaks join-invariant test) + the quick DeskShowcase "A $1M terminal" β†’ on-voice deadpan copy fix. Quick, independent, no mockup dep. 2. **Wave 2 β€” Step 2 entity layer, SPORT-AGNOSTIC (the eyeball gate).** (a) ingestion-time athlete-id capture per league (ESPN cross-sport id + MLB MLBAM) with resolved-or-404 status stored (Addition 1); (b) ONE `(league,id)`β†’headshot resolver; (c) thread id β†’ grade β†’ strip β†’ every surface (slate strips, scan search, hot list, grade cards); (d) ledger book wordmarks β€” import `BookWordmark` + add missing keys + **bundle the ~8 real book SVGs**; (e) team-logo coverage gaps. **STOP + REPORT for founder eyeball of real logos/headshots across ALL sports before the rest builds on it.** Report any league with no free id source. 3. **Wave 3 β€” Record-by-grade-tier (Addition 2, core)** β€” extend `getModelAggregate` to per-tier buckets + render on dashboard, ledger, /u. Sets up /u + the credibility story. 4. **Wave 4 β€” Step 3 outlook mode** (never-empty grid β†’ receipts/tomorrow) + Step 4 wire-able-now surfaces toward the mockup: live grade-shift timeline (grade-by-game-clock, historyΒ·last-24), market-breadth/consensus strip, Correlation Builder / Parlay Lab page (parlay slip + correlation flag). 5. **Wave 5 β€” /u house-mode profile** (D2, reads `user_id=NULL` aggregate; portrait + OG crops from the mockup) + **pitcher identity card** fed by the **Baseball Savant arsenal adapter** (D4, new free feed). 6. **Wave 6 β€” Step 5 combat** (author the combat spec FIRST β†’ free honest v1: ESPN-MMA + odds-api ML/round-totals + style-blend archetype set + tale-of-the-tape card from the mockup; method/round/props flagged data-limited; matchup-grade engine its own sub-wave; confirm before any scraping dep). Global rule for every wave: build the surface toward `specs/design-reference/vyndr-system.html`; keep the live wordmark. Ship each wave on green (tests + `next build` exit 0); nothing to main without the founder's word.