Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
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:128renderssubscription_endverbatim from the Supabaseuser_profilesrow. 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-50writessubscription_end = now + 30 days(a synthetic guess). The 2036 value is a manually-seeded/comped founder-account row the UI trusts blindly. The real Stripecurrent_period_endexists atGET /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 asubscription_endthe provider didn't assert → fall back to—.
1b. James Wood → "Chicago Cubs" / builds vs AL East — ✅ fixable (nameKey collision)
- Root cause:
mlbStatsAdapter.js:192people.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 → wrongcurrentTeam. 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:315teamByPlayer+ rosterlogs opponents →streaksService.js:229team+ 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
teamHinttosearchPlayer/getPlayerStats/resolveStats; on multiple exact-nameKey matches pick the one whosecurrentTeammatches the hint, else return null (never guess — S59 doctrine). InsnapshotService.js:311pass the prop's game team as the hint and dropstats.team/rawLog if it disagrees with the prop's game participants. - Test: join-invariant — a resolved player's
teammust equal / be a participant of the prop's game; a rosterlogs entry'steammust 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 <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
BookWordmarkis brand-color text. Decision below (D3) — text-wordmark ships today; SVGs are an upgrade. - Headshot normalization:
PlayerAvataralready 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-1013emptyStateCopy) 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 atSlate.tsx:609is the networkfetchErrorstate, 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-awareemptyStateCopy. 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 bothSlate.tsx:1001anddashboard/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.jsmma:{active:false,comingSoon}, a share-card color, a dormant ESPN URL inESPNAdapter.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 inESPNAdapter.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.
- ESPN MMA (
- 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
analyzeViaEngine1per-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.
DECISIONS FOR THE FOUNDER (my honest defaults in bold)
- 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 publicuser_id=NULLaggregate (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
BookWordmarknow (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).
PROPOSED WAVE SEQUENCE (ship on green, per step)
- Wave 1 — Step 1 trust bugs (billing date + Wood nameKey collision + join-invariant test). Quick, independent.
- Wave 2 — Step 2 entity layer (MLB headshot thread-through on 3 surfaces + ledger wordmark import + books.js keys). Eyeball gate: report when this lands so real logos/headshots can be verified before the rest builds on it.
- Wave 3 — Step 3 outlook mode (never-empty grid) + Step 4 wire-able-now surfaces (grade-shift timeline, consensus strip, Parlay Lab page).
- Wave 4 — Step 4 new feed (pitcher arsenal via Savant) + /u house-profile mode (pending D2).
- Wave 5 — Step 5 combat (spec first, then free v1 pending D5).
Also queued (from the work order preamble): remove the literal "A $1M terminal" DeskShowcase headline → on-voice deadpan (it claims premium instead of showing it).