Files
vyndr/specs/wiring-data-train.md
T
builtbykev 93a220e0ca Design reference (mockup) + map updated: sport-agnostic entity, per-tier record, additions
- specs/design-reference/vyndr-system.html + support.js (global visual reference)
- D1 override (sport-agnostic athlete-id ingestion), D3 override (book SVGs in-wave)
- Addition 1 (ingestion-time id resolve+store), Addition 2 (record by grade tier),
  Addition 3 (mockup is global reference; live wordmark kept)
- revised 6-wave sequence

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 03:24:45 -04:00

17 KiB
Raw Blame History

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: the live web payment path is NexaPay, not Stripe. web/src/app/api/webhook/nexapay/route.ts:41-50 writes subscription_end = now + 30 days (a synthetic guess). 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 a provider-asserted value (consume /api/stripe/status.current_period_end, or have the NexaPay webhook persist its real next-bill timestamp instead of now+30); 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.buildPlayerStripsFromPropsStatStrip.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 <img> → 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 <Slate> 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_artsalready 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 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.