From 69b1febb0f460c0ed2e5de68abfc435d1fcfc914 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 02:55:43 -0400 Subject: [PATCH 01/15] =?UTF-8?q?Step=200=20MAP:=20wiring=20&=20data=20tra?= =?UTF-8?q?in=20=E2=80=94=20surface-by-surface=20data/asset=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/wiring-data-train.md | 99 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 specs/wiring-data-train.md diff --git a/specs/wiring-data-train.md b/specs/wiring-data-train.md new file mode 100644 index 0000000..9ecaf2a --- /dev/null +++ b/specs/wiring-data-train.md @@ -0,0 +1,99 @@ +# 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.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. + +--- + +## 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 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). + +--- + +## PROPOSED WAVE SEQUENCE (ship on green, per step) +1. **Wave 1 β€” Step 1 trust bugs** (billing date + Wood nameKey collision + join-invariant test). Quick, independent. +2. **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.** +3. **Wave 3 β€” Step 3 outlook mode** (never-empty grid) + Step 4 wire-able-now surfaces (grade-shift timeline, consensus strip, Parlay Lab page). +4. **Wave 4 β€” Step 4 new feed** (pitcher arsenal via Savant) + /u house-profile mode (pending D2). +5. **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). From 93a220e0ca84b09927929cd2bd9c2e87e1487078 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 03:24:45 -0400 Subject: [PATCH 02/15] 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) --- specs/design-reference/support.js | 1768 ++++++++++++++++++++++ specs/design-reference/vyndr-system.html | 1571 +++++++++++++++++++ specs/wiring-data-train.md | 34 +- 3 files changed, 3365 insertions(+), 8 deletions(-) create mode 100644 specs/design-reference/support.js create mode 100644 specs/design-reference/vyndr-system.html diff --git a/specs/design-reference/support.js b/specs/design-reference/support.js new file mode 100644 index 0000000..6ca00e3 --- /dev/null +++ b/specs/design-reference/support.js @@ -0,0 +1,1768 @@ +// GENERATED from dc-runtime/src/*.ts β€” do not edit. Rebuild with `cd dc-runtime && bun run build`. +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/react.ts + function getReact() { + const R = window.React; + if (!R) throw new Error("dc-runtime: window.React is not available yet"); + return R; + } + function getReactDOM() { + const RD = window.ReactDOM; + if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); + return RD; + } + var h = ((...args) => getReact().createElement( + ...args + )); + + // src/parse.ts + function parseDcDocument(doc) { + const dc = doc.querySelector("x-dc"); + if (!dc) return null; + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template: dc.innerHTML, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDcText(src) { + const openMatch = /]*)?>/.exec(src); + if (!openMatch) return null; + const close = src.lastIndexOf(""); + if (close === -1 || close < openMatch.index) return null; + const template = src.slice(openMatch.index + openMatch[0].length, close); + const doc = new DOMParser().parseFromString(src, "text/html"); + const scriptEl = doc.querySelector("script[data-dc-script]"); + const { props, preview } = parseDataProps( + scriptEl?.getAttribute("data-props") ?? null + ); + return { + template, + js: scriptEl ? scriptEl.textContent || "" : "", + props, + preview + }; + } + function parseDataProps(raw) { + if (!raw) return { props: null, preview: null }; + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { props: null, preview: null }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { props: null, preview: null }; + } + const obj = parsed; + const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; + const rest = {}; + for (const k of Object.keys(obj)) { + if (k[0] !== "$") rest[k] = obj[k]; + } + return { props: Object.keys(rest).length ? rest : null, preview }; + } + function dcNameFromPath(pathname) { + let p = pathname || ""; + try { + p = decodeURIComponent(p); + } catch { + } + const base = p.split("/").pop() || "Root"; + return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; + } + + // src/boot.ts + var BASE_CSS = ` + .sc-placeholder{background:color-mix(in srgb,currentColor 8%,transparent); + border:1px solid color-mix(in srgb,currentColor 50%,transparent); + border-radius:2px;box-sizing:border-box;overflow:hidden} + @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} + html.sc-dc-streaming .sc-placeholder, + html.sc-dc-streaming .sc-interp.sc-missing{position:relative; + background:color-mix(in srgb,currentColor 5%,transparent); + border-color:transparent} + html.sc-dc-streaming .sc-placeholder::before, + html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; + position:absolute;inset:0;pointer-events:none; + background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); + background-size:400% 100%;animation:sc-shine 1.4s ease infinite} + html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, + html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; + background:color-mix(in srgb,currentColor 8%,transparent)} + .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; + color:color-mix(in srgb,currentColor 70%,transparent);word-break:break-word} + .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; + vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); + border-radius:2px;box-sizing:border-box;color:transparent; + user-select:none} + .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; + color:color-mix(in srgb,currentColor 50%,transparent); + background:color-mix(in srgb,currentColor 10%,transparent);border-radius:3px; + padding:0 3px} + .sc-host.sc-has-error{position:relative} + .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; + padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; + border-radius:4px;white-space:pre-wrap;pointer-events:none} + /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both + in sync until dc-runtime regains a build step. */ + @media print { + @page { margin: 0.5cm; } + figure, table { break-inside: avoid; } + #dc-root, #dc-root > .sc-host { height: auto; } + *, *::before, *::after { + print-color-adjust: exact; -webkit-print-color-adjust: exact; + backdrop-filter: none !important; -webkit-backdrop-filter: none !important; + animation-delay: -99s !important; animation-duration: .001s !important; + animation-iteration-count: 1 !important; animation-fill-mode: both !important; + animation-play-state: running !important; transition-duration: 0s !important; + } + } + `; + var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; + function rootNameForDocument(doc, loc) { + let bootPath = loc.pathname || ""; + if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { + try { + bootPath = new URL(doc.baseURI || "/").pathname; + } catch { + } + } + return dcNameFromPath(bootPath); + } + function safeDecode(s) { + try { + return decodeURIComponent(s); + } catch { + return s; + } + } + function boot(runtime, doc = document) { + const parsed = parseDcDocument(doc); + if (!parsed) return null; + const React = getReact(); + const rootName = rootNameForDocument(doc, location); + runtime.markFetched(rootName); + runtime.setRootName(rootName); + runtime.adoptParsed(rootName, parsed); + if (!window.__resources) { + fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { + const raw = t ? parseDcText(t) : null; + if (raw?.template) runtime.updateHtml(rootName, raw.template); + }).catch(() => { + }); + } + const dc = doc.querySelector("x-dc"); + const hostEl = doc.createElement("div"); + hostEl.id = "dc-root"; + dc.replaceWith(hostEl); + if (!parsed.preview) { + const s = doc.createElement("style"); + s.textContent = FULL_PAGE_CSS; + doc.head.appendChild(s); + } + const Root = runtime.getDC(rootName); + const entry = runtime.registry.get(rootName); + function StandaloneRoot() { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + entry.subs.add(sub); + return () => { + entry.subs.delete(sub); + }; + }, []); + const defaults = React.useMemo(() => { + const d = {}; + for (const k in entry.propsMeta || {}) { + const v = entry.propsMeta?.[k]?.default; + if (v !== void 0) d[k] = v; + } + return d; + }, [entry.propsMeta]); + return h(Root, { ...defaults, ...entry.propOverrides || {} }); + } + const ReactDOM = getReactDOM(); + if (ReactDOM.createRoot) + ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); + else ReactDOM.render(h(StandaloneRoot), hostEl); + return rootName; + } + + // src/expr.ts + var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; + var NUMBER_RE = /^-?\d+(\.\d+)?$/; + function resolve(vals, src) { + const expr = String(src).trim(); + if (!expr) return void 0; + if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { + return resolve(vals, expr.slice(1, -1)); + } + const eq = findTopLevelEquality(expr); + if (eq) { + const lv = resolve(vals, expr.slice(0, eq.index)); + const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); + switch (eq.op) { + case "===": + return lv === rv; + case "!==": + return lv !== rv; + case "==": + return lv == rv; + default: + return lv != rv; + } + } + if (expr[0] === "!") return !resolve(vals, expr.slice(1)); + if (expr === "true") return true; + if (expr === "false") return false; + if (expr === "null") return null; + if (expr === "undefined") return void 0; + if (NUMBER_RE.test(expr)) return Number(expr); + if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { + return expr.slice(1, -1); + } + return resolvePath(vals, expr); + } + function parensWrapWhole(expr) { + let depth = 0; + for (let i = 0; i < expr.length - 1; i++) { + if (expr[i] === "(") depth++; + else if (expr[i] === ")") { + depth--; + if (depth === 0) return false; + } + } + return true; + } + function findTopLevelEquality(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i++) { + const c = expr[i]; + if (c === "[" || c === "(") depth++; + else if (c === "]" || c === ")") depth--; + else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { + if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; + if (!expr.slice(0, i).trim()) continue; + const op = expr[i + 2] === "=" ? c + "==" : c + "="; + return { index: i, op }; + } + } + return null; + } + function resolvePath(vals, expr) { + const head = expr.match(IDENT_RE); + if (!head) return void 0; + let cur = vals == null ? void 0 : vals[head[0]]; + let i = head[0].length; + while (i < expr.length) { + if (expr[i] === ".") { + const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); + if (!m) return void 0; + cur = cur == null ? void 0 : cur[m[0]]; + i += 1 + m[0].length; + } else if (expr[i] === "[") { + let depth = 1; + let j = i + 1; + while (j < expr.length && depth > 0) { + if (expr[j] === "[") depth++; + else if (expr[j] === "]") { + depth--; + if (depth === 0) break; + } + j++; + } + if (depth !== 0) return void 0; + const key = resolve(vals, expr.slice(i + 1, j)); + cur = cur == null ? void 0 : cur[key]; + i = j + 1; + } else { + return void 0; + } + } + return cur; + } + + // src/encode.ts + var CAMEL_ATTR = "sc-camel-"; + var INLINE_TEXT_TAGS = new Set( + "a abbr b bdi bdo br cite code del dfn em i ins kbd mark q s samp small span strike strong sub sup u var wbr".split( + " " + ) + ); + var RAW_WRAP = { + select: "sc-raw-select", + table: "sc-raw-table", + tbody: "sc-raw-tbody", + thead: "sc-raw-thead", + tfoot: "sc-raw-tfoot", + tr: "sc-raw-tr", + td: "sc-raw-td", + th: "sc-raw-th", + caption: "sc-raw-caption" + }; + var RAW_UNWRAP = Object.fromEntries( + Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) + ); + var EVENT_MAP = { + onclick: "onClick", + onchange: "onChange", + oninput: "onInput", + onsubmit: "onSubmit", + onkeydown: "onKeyDown", + onkeyup: "onKeyUp", + onkeypress: "onKeyPress", + onmousedown: "onMouseDown", + onmouseup: "onMouseUp", + onmouseenter: "onMouseEnter", + onmouseleave: "onMouseLeave", + onfocus: "onFocus", + onblur: "onBlur", + ondoubleclick: "onDoubleClick", + oncontextmenu: "onContextMenu", + onmousemove: "onMouseMove", + onmouseover: "onMouseOver", + onmouseout: "onMouseOut", + onpointerdown: "onPointerDown", + onpointerup: "onPointerUp", + onpointermove: "onPointerMove", + onpointerenter: "onPointerEnter", + onpointerleave: "onPointerLeave", + onpointercancel: "onPointerCancel", + onpointerover: "onPointerOver", + onpointerout: "onPointerOut", + ongotpointercapture: "onGotPointerCapture", + onlostpointercapture: "onLostPointerCapture", + ontouchstart: "onTouchStart", + ontouchend: "onTouchEnd", + ontouchmove: "onTouchMove", + ontouchcancel: "onTouchCancel", + ondragstart: "onDragStart", + ondragend: "onDragEnd", + ondragenter: "onDragEnter", + ondragleave: "onDragLeave", + ondragover: "onDragOver", + onanimationstart: "onAnimationStart", + onanimationend: "onAnimationEnd", + onanimationiteration: "onAnimationIteration", + ontransitionend: "onTransitionEnd" + }; + var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; + var IMPORT_SELF_CLOSE_RE = new RegExp( + "<(x-import|dc-import)(" + ATTRS + ")/>", + "gi" + ); + var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; + function encodeCamelAttrs(html) { + return html.replace( + CAMEL_ATTR_RE, + (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq + ); + } + function encodeCase(html) { + html = html.replace( + IMPORT_SELF_CLOSE_RE, + (_, t, a) => "<" + t + a + ">" + ); + html = html.replace(/)/gi, "/gi, ""); + html = encodeCamelAttrs(html); + for (const [real, alias] of Object.entries(RAW_WRAP)) { + html = html.replace( + new RegExp("(])", "gi"), + "$1" + alias + ); + } + return html; + } + function kebabToCamel(s) { + return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + } + function cssToObj(css) { + const o = {}; + for (const decl of css.split(";")) { + const i = decl.indexOf(":"); + if (i < 0) continue; + const prop = decl.slice(0, i).trim(); + o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); + } + return o; + } + function compileAttr(raw) { + const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); + if (whole) { + const path = whole[1]; + return (vals) => resolve(vals, path); + } + if (raw.includes("{{")) { + const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); + return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); + } + return () => raw; + } + + // src/compile.ts + function collectProps(node, kind, host) { + const propGetters = []; + const pseudoClasses = []; + let hintSize = null; + for (const { name, value } of [...node.attributes]) { + if (name === "sc-name" || name === "data-dc-tpl") continue; + let key = name; + if (key.startsWith(CAMEL_ATTR)) + key = kebabToCamel(key.slice(CAMEL_ATTR.length)); + if (key === "hint-size") { + hintSize = value; + continue; + } + if (key.startsWith("style-")) { + pseudoClasses.push(host.pseudoClass(key.slice(6), value)); + continue; + } + if (kind !== "dom") { + if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) + key = kebabToCamel(key); + } else { + if (key === "class") key = "className"; + else if (key === "for") key = "htmlFor"; + else if (key.startsWith("on")) + key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); + } + propGetters.push([key, compileAttr(value)]); + } + return { propGetters, pseudoClasses, hintSize }; + } + var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ + "position", + "left", + "right", + "top", + "bottom", + "inset", + "width", + "height", + "z-index", + "transform" + ]); + function hostPositionStyle(style) { + const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; + if (!all) return void 0; + const out = {}; + for (const [k, v] of Object.entries(all)) { + const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); + if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; + } + return Object.keys(out).length ? out : void 0; + } + function compileTemplate(html, host) { + const tpl = document.createElement("template"); + //! nosemgrep: direct-inner-html-assignment + tpl.innerHTML = encodeCase(html); + let tplN = 0; + (function stamp(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + node.setAttribute("data-dc-tpl", String(tplN++)); + } + for (const c of node.childNodes) stamp(c); + })(tpl.content); + const builders = walkChildren(tpl.content, host); + const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); + render.__annotated = tpl.innerHTML; + return render; + } + function walkChildren(node, host) { + return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); + } + function walk(node, host) { + if (node.nodeType === Node.TEXT_NODE) return walkText(node); + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const el = node; + const tag = el.tagName.toLowerCase(); + if (tag === "sc-for") return walkFor(el, host); + if (tag === "sc-if") return walkIf(el, host); + if (tag === "x-import") return walkXImport(el, host); + if (tag === "sc-helmet") return host.helmet(el); + if (tag === "dc-import") return walkComponent(el, host); + return walkElement(el, host); + } + var warnedHoles = /* @__PURE__ */ new Set(); + function warnUnresolved(ctx, what) { + const key = (ctx?.__name || "?") + "\0" + what; + if (warnedHoles.has(key)) return; + warnedHoles.add(key); + console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); + } + function walkText(node) { + const txt = node.nodeValue ?? ""; + if (!txt.includes("{{")) { + if (!txt.trim() && !txt.includes(" ")) return null; + return () => txt; + } + const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); + return (vals, ctx, key) => h( + getReact().Fragment, + { key }, + ...parts.map((p, i) => { + if (!(i & 1)) return p; + const v = resolve(vals, p); + if (v === void 0) { + if (!ctx?.__streamingNow) { + if (document.body?.hasAttribute("data-dc-editor-on")) { + return h( + "span", + { key: i, className: "sc-interp sc-unresolved" }, + "{{ " + p.trim() + " }}" + ); + } + warnUnresolved( + ctx, + "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" + ); + return null; + } + return h( + "span", + { key: i, className: "sc-interp sc-missing" }, + p.trim() + ); + } + if (getReact().isValidElement(v) || Array.isArray(v)) { + return h(getReact().Fragment, { key: i }, v); + } + if (v === null || typeof v === "boolean") return null; + return h("span", { key: i, className: "sc-interp" }, String(v)); + }) + ); + } + function walkFor(el, host) { + const listGet = compileAttr(el.getAttribute("list") || ""); + const asName = el.getAttribute("as") || "item"; + const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); + const kids = walkChildren(el, host); + const listSrc = el.getAttribute("list") || ""; + return (vals, ctx, key) => { + let list = listGet(vals); + if (!Array.isArray(list)) { + if (!ctx?.__streamingNow) { + if (list !== void 0 && list !== null) { + warnUnresolved( + ctx, + 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" + ); + } + list = []; + } else { + list = hintN > 0 ? Array(hintN).fill(void 0) : []; + } + } + return h( + getReact().Fragment, + { key }, + list.map((item, i) => { + const sub = { ...vals, [asName]: item, $index: i }; + return h( + getReact().Fragment, + { key: i }, + kids.map((b, j) => b(sub, ctx, j)) + ); + }) + ); + }; + } + function walkIf(el, host) { + const valGet = compileAttr(el.getAttribute("value") || ""); + const hintRaw = el.getAttribute("hint-placeholder-val"); + const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + let v = valGet(vals); + if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); + return v ? h( + getReact().Fragment, + { key }, + kids.map((b, j) => b(vals, ctx, j)) + ) : null; + }; + } + function walkComponent(el, host) { + const name = el.getAttribute("name") || el.getAttribute("component") || ""; + el.removeAttribute("name"); + el.removeAttribute("component"); + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const { propGetters, hintSize } = collectProps(el, "dc-import", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key, + __hintSize: hintSize, + __tplId: tplId, + __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 + }; + for (const [k, g] of propGetters) { + const v = g(vals); + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return h(host.component(name), props); + }; + } + function walkXImport(el, host) { + const globalNameGet = compileAttr( + el.getAttribute("component-from-global-scope") || "" + ); + const exportNameGet = compileAttr( + el.getAttribute("component") || el.getAttribute("name") || "" + ); + const fromRaw = el.getAttribute("from") || (el.getAttribute("component-from-global-scope") ? "" : el.getAttribute("src") || el.getAttribute("import") || ""); + const urls = fromRaw.trim() ? fromRaw.trim().split(/\s+/) : []; + const url = urls.length ? urls[urls.length - 1] : ""; + const kindOf = (u) => /\.(jsx|tsx)(\?|#|$)/i.test(u) ? "jsx" : "js"; + const tplId = el.getAttribute("data-dc-tpl"); + const styleRaw = el.getAttribute("style"); + el.removeAttribute("style"); + const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; + const wrap = tplId != null || styleGet != null; + const { propGetters, hintSize } = collectProps(el, "x-import", host); + const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); + const kids = hasContent ? walkChildren(el, host) : []; + const urlBindable = fromRaw.includes("{{"); + if (urls.length && !urlBindable) { + let prev; + for (const u of urls) prev = host.loadExternal(kindOf(u), u, prev); + } + const evalName = (g, vals) => { + const v = g(vals); + const s = v == null ? "" : String(v); + return s.includes("{{") ? "" : s; + }; + return (vals, ctx, key) => { + const globalName = evalName(globalNameGet, vals); + const name = globalName || evalName(exportNameGet, vals); + const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); + const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; + const wrapper = wrap ? { + key, + className: "sc-host-x", + "data-dc-tpl": tplId, + style: hostStyle || { display: "contents" } + } : null; + if (!C) { + const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + const props = wrapper ? {} : { key }; + let unresolvedHole = false; + for (const [k, g] of propGetters) { + if (k === "component" || k === "componentFromGlobalScope" || k === "from") { + continue; + } + const v = g(vals); + if (v === void 0) unresolvedHole = true; + if (k === "dcProps") { + if (v && typeof v === "object") Object.assign(props, v); + continue; + } + props[k] = v; + } + if (unresolvedHole && ctx?.__htmlStreamingNow) { + const ph = host.placeholder({ + key: wrapper ? void 0 : key, + name, + hintSize, + error: null + }); + return wrapper ? h("div", wrapper, ph) : ph; + } + if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); + return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); + }; + } + function contentKey(el) { + const clone = el.cloneNode(true); + for (const d of clone.querySelectorAll("*")) { + while (d.attributes.length) d.removeAttribute(d.attributes[0].name); + } + const s = clone.innerHTML; + let h2 = 5381; + for (let i = 0; i < s.length; i++) h2 = (h2 << 5) + h2 + s.charCodeAt(i) | 0; + return s.length + "." + (h2 >>> 0).toString(36); + } + var NEVER_CONTENT_KEYED = new Set( + "script style textarea option title select canvas iframe video audio".split( + " " + ) + ); + var NOT_INLINE_SELECTOR = ":not(" + [...INLINE_TEXT_TAGS].join(",") + ")"; + function walkElement(el, host) { + const realTag = RAW_UNWRAP[el.localName] || el.localName; + const tplId = el.getAttribute("data-dc-tpl"); + const inlineOnly = el.childNodes.length > 0 && !NEVER_CONTENT_KEYED.has(realTag) && el.querySelector(NOT_INLINE_SELECTOR) === null; + const keySuffix = inlineOnly ? "|" + contentKey(el) : ""; + const { propGetters, pseudoClasses } = collectProps(el, "dom", host); + const kids = walkChildren(el, host); + return (vals, ctx, key) => { + const props = { + key: key + keySuffix, + "data-dc-tpl": tplId + }; + for (const [k, g] of propGetters) { + let v = g(vals); + if (k === "style" && typeof v === "string") v = cssToObj(v); + if ((k === "value" || k === "checked") && v === void 0) { + v = k === "checked" ? false : ""; + } + props[k] = v; + } + if (pseudoClasses.length) { + props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); + } + return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); + }; + } + + // src/logic.ts + var StreamableLogic = class { + constructor(props) { + __publicField(this, "props"); + __publicField(this, "state", {}); + /** Back-pointer to the wrapper component, installed after construction. */ + __publicField(this, "__host"); + this.props = props || {}; + } + setState(update, cb) { + this.__host && this.__host.__setLogicState(update, cb); + } + forceUpdate() { + this.__host && this.__host.forceUpdate(); + } + componentDidMount() { + } + componentDidUpdate(_prevProps) { + } + componentWillUnmount() { + } + /** The flat object the template renders against (merged over props). */ + renderVals() { + return {}; + } + }; + function evalDcLogic(src) { + //! nosemgrep: eval-and-function-constructor + const fn = new Function( + "DCLogic", + "StreamableLogic", + "React", + src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' + ); + return fn(StreamableLogic, StreamableLogic, getReact()); + } + + // src/component.ts + function shallowEqual(a, b) { + if (!b) return false; + const ak = Object.keys(a).filter((k) => k !== "children"); + const bk = Object.keys(b).filter((k) => k !== "children"); + if (ak.length !== bk.length) return false; + for (const k of ak) if (a[k] !== b[k]) return false; + return true; + } + function Placeholder({ + name, + hintSize, + streaming, + error + }) { + const [w, hgt] = (hintSize || "100%,60px").split(","); + return h( + "div", + { + className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), + style: { width: w.trim(), height: hgt && hgt.trim() }, + title: name + }, + error ? h( + "div", + { className: "sc-placeholder-error" }, + (name ? name + ": " : "") + error + ) : null + ); + } + function hintToMin(hint) { + if (!hint) return void 0; + const [w, hgt] = hint.split(","); + return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; + } + function createComponentFactory(registry, ensureFetched) { + const React = getReact(); + const AncestorContext = React.createContext([]); + class StreamableComponent extends React.Component { + constructor(props) { + super(props); + __publicField(this, "__name"); + __publicField(this, "__sub"); + __publicField(this, "__needsDidMount", false); + /** Snapshot of the registry's streaming flags taken at render time β€” + * builders read it off the RenderCtx (this) to pick placeholder vs + * render-nothing for unresolved values. */ + __publicField(this, "__streamingNow", false); + __publicField(this, "__htmlStreamingNow", false); + /** When a construct throws, remember the (class, registry.ver, props) + * triple so render-time reconcile doesn't re-attempt it on every parent + * re-render. A registry bump (new class, template, external module + * resolving via bumpAll) changes `ver` and breaks the memo so an + * env-dependent constructor can self-heal. */ + __publicField(this, "__failedLogic", null); + __publicField(this, "__failedUserProps", null); + __publicField(this, "__failedVer", -1); + /** Per-instance constructor error β€” kept here (not on the registry entry) + * so one instance's successful construct can't hide a sibling's failure, + * and a construct can never wipe an eval error `updateJs` recorded on + * `r.logicError`. */ + __publicField(this, "__ctorError", null); + __publicField(this, "logic"); + this.__name = props.__name; + this.state = { __v: 0, __err: null }; + this.__sub = () => { + if (this.state.__err) this.setState({ __err: null }); + this.forceUpdate(); + }; + this.__makeLogic(registry.get(this.__name).Logic, null); + ensureFetched(this.__name); + } + /** Error-boundary hook: a render crash anywhere in this DC's subtree + * (its own template, an x-import'd component, a child DC without its + * own deeper boundary) lands here instead of unmounting the page. */ + static getDerivedStateFromError(e) { + return { __err: e instanceof Error && e.message ? e.message : String(e) }; + } + componentDidCatch(e, info) { + console.error( + "[dc-runtime] render error in <" + this.__name + ">:", + e, + info?.componentStack || "" + ); + } + /** Instantiate the logic class (or the no-op base) and adopt `prevState` + * over its initial state β€” used both at mount and on hot-swap. */ + __makeLogic(Logic, prevState) { + const L = Logic || StreamableLogic; + try { + this.logic = new L(this.__userProps()); + this.__failedLogic = null; + this.__failedUserProps = null; + this.__ctorError = null; + } catch (e) { + console.error(e); + this.__failedLogic = Logic; + this.__failedUserProps = this.__userProps(); + this.__failedVer = registry.get(this.__name).ver; + this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); + this.logic = new StreamableLogic( + this.__userProps() + ); + } + this.logic.__host = this; + if (prevState) + this.logic.state = { ...this.logic.state || {}, ...prevState }; + } + /** The props the author's logic + template see β€” internal __-prefixed + * wiring stripped. */ + __userProps() { + const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; + return rest; + } + __setLogicState(update, cb) { + const prev = this.logic.state; + const patch = typeof update === "function" ? update(prev) : update; + this.logic.state = { ...prev, ...patch }; + this.setState((s) => ({ __v: s.__v + 1 }), cb); + } + /** Swap the logic instance when the registry's Logic class changed + * (streaming completion, hot reload). State carries over; didMount + * re-fires after the swap commits so refs exist. */ + __reconcileLogic() { + const r = registry.get(this.__name); + const Next = r.Logic; + const Cur = this.logic.constructor; + if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { + return; + } + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + this.__makeLogic(Next, this.logic.state); + this.__needsDidMount = true; + } + componentDidMount() { + registry.get(this.__name).subs.add(this.__sub); + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } + componentDidUpdate(prevProps) { + this.logic.props = this.__userProps(); + if (this.__needsDidMount) { + if (this.state.__err || !registry.get(this.__name).tpl) return; + this.__needsDidMount = false; + try { + this.logic.componentDidMount(); + } catch (e) { + console.error(e); + } + } else { + try { + this.logic.componentDidUpdate(prevProps); + } catch (e) { + console.error(e); + } + } + } + componentWillUnmount() { + registry.get(this.__name).subs.delete(this.__sub); + if (!this.__needsDidMount) { + try { + this.logic.componentWillUnmount(); + } catch (e) { + console.error(e); + } + } + } + render() { + const r = registry.get(this.__name); + const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); + const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; + const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; + const hostBase = { + className: cls, + style: hostStyle, + "data-sc-name": this.__name, + "data-dc-tpl": this.props.__tplId + }; + const chain = Array.isArray(this.context) ? this.context : []; + if (chain.includes(this.__name)) { + const cycle = [ + ...chain.slice(chain.indexOf(this.__name)), + this.__name + ].join(" \u2192 "); + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: "circular import: " + cycle + }) + ); + } + if (this.state.__err) { + return h( + "div", + { ...hostBase, className: cls + " sc-has-error" }, + h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + this.__name + ": " + this.state.__err + ), + h(Placeholder, { + name: this.__name, + hintSize: this.props.__hintSize, + error: this.state.__err + }) + ); + } + this.__reconcileLogic(); + if (!r.tpl) { + return h( + "div", + hostBase, + h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) + ); + } + const userProps = this.__userProps(); + this.logic.props = userProps; + let vals = userProps; + let renderErr = r.logicError || this.__ctorError; + try { + vals = { ...userProps, ...this.logic.renderVals() || {} }; + } catch (e) { + console.error(e); + renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); + } + this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); + this.__htmlStreamingNow = !!r.htmlStreaming; + return h( + "div", + { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, + renderErr && h( + "div", + { className: "sc-logic-error", "data-omelette-chrome": "" }, + renderErr + ), + h( + AncestorContext.Provider, + { value: [...chain, this.__name] }, + r.tpl(vals, this) + ) + ); + } + } + __publicField(StreamableComponent, "contextType", AncestorContext); + const named = /* @__PURE__ */ new Map(); + function getDC(name) { + const hit = named.get(name); + if (hit) return hit; + function Dispatcher(p) { + const [, setTick] = React.useState(0); + React.useEffect(() => { + const sub = () => setTick((n) => n + 1); + registry.get(name).subs.add(sub); + return () => { + registry.get(name).subs.delete(sub); + }; + }, []); + ensureFetched(name); + return h(StreamableComponent, { ...p, __name: name }); + } + Dispatcher.displayName = name; + named.set(name, Dispatcher); + return Dispatcher; + } + return { + getDC, + StreamableComponent + }; + } + + // src/bundled.ts + function bundledBlob(url) { + const blobs = window.__resourceBlobs; + const b = blobs ? blobs[url.split("#")[0]] : void 0; + return b instanceof Blob ? b : null; + } + + // src/cdn.ts + var REACT_URL = "https://unpkg.com/react@18.3.1/umd/react.production.min.js"; + var REACT_SRI = "sha384-DGyLxAyjq0f9SPpVevD6IgztCFlnMF6oW/XQGmfe+IsZ8TqEiDrcHkMLKI6fiB/Z"; + var REACT_DOM_URL = "https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"; + var REACT_DOM_SRI = "sha384-gTGxhz21lVGYNMcdJOyq01Edg0jhn/c22nsx0kyqP0TxaV5WVdsSH1fSDUf5YJj1"; + var BABEL_URL = "https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"; + var BABEL_SRI = "sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y"; + function cdnScriptFor(url, sri) { + const res = window.__resources; + const v = res ? res[url] : void 0; + return typeof v === "string" && v ? { src: v } : { src: url, integrity: sri }; + } + + // src/external.ts + var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); + function isRenderableType(g) { + if (typeof g === "function") return !isElementClass(g); + return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; + } + function resolveDottedPath(root, name) { + let cur = root; + for (const seg of name.split(".")) { + if (cur == null) return void 0; + cur = cur[seg]; + } + return cur; + } + var GLOBAL_POLL_INTERVAL_MS = 50; + var GLOBAL_POLL_TIMEOUT_MS = 3e4; + function createExternalModules(onResolved) { + const cache = /* @__PURE__ */ new Map(); + let babelLoading = null; + const reportedMissing = /* @__PURE__ */ new Map(); + const polling = /* @__PURE__ */ new Set(); + function ensureBabel() { + if (window.Babel) return Promise.resolve(); + if (babelLoading) return babelLoading; + const babel = cdnScriptFor(BABEL_URL, BABEL_SRI); + babelLoading = new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = babel.src; + if (babel.integrity) { + s.integrity = babel.integrity; + s.crossOrigin = "anonymous"; + } + s.onload = () => res(); + s.onerror = rej; + document.head.appendChild(s); + }); + return babelLoading; + } + const pending = /* @__PURE__ */ new Map(); + function load(kind, url, after) { + const existing = pending.get(url); + if (existing) return existing; + cache.set(url, null); + console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); + const ready = Promise.all([ + kind === "jsx" ? ensureBabel() : Promise.resolve(), + after ?? Promise.resolve() + ]); + const p = ready.then(() => { + const pre = bundledBlob(url); + if (pre) return pre.text(); + return fetch(url).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.text(); + }); + }).then((src) => { + const code = kind === "jsx" ? window.Babel.transform(src, { + filename: url, + presets: ["react", "typescript"] + }).code : src; + const module = { exports: {} }; + const before = new Set(Object.keys(window)); + //! nosemgrep: eval-and-function-constructor + new Function("React", "module", "exports", "require", code)( + getReact(), + module, + module.exports, + () => ({}) + ); + const globals = {}; + for (const k of Object.keys(window)) { + if (!before.has(k) && typeof window[k] === "function") { + globals[k] = window[k]; + } + } + cache.set(url, { mod: module.exports, globals }); + console.info( + "[dc-runtime] x-import: loaded", + url, + "\u2014 exports:", + Object.keys(module.exports), + "window globals:", + Object.keys(globals) + ); + onResolved(); + }).catch((e) => { + cache.set(url, { + mod: {}, + globals: {}, + error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) + }); + console.error( + "[dc-runtime] x-import: FAILED to load", + url, + "(" + kind + ")", + e + ); + onResolved(); + }); + pending.set(url, p); + return p; + } + function resolve2(url, name) { + const entry = cache.get(url); + if (!entry) return null; + const { mod, globals } = entry; + const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; + if (typeof C === "function") return C; + const key = url + "\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set( + key, + entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" + ); + console.error( + "[dc-runtime] x-import: module", + url, + "loaded but has no component named", + JSON.stringify(name), + "\u2014 available exports:", + Object.keys(mod), + "window globals:", + Object.keys(globals), + ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." + ); + } + return null; + } + function waitForGlobal(name) { + if (polling.has(name)) return; + polling.add(name); + const started = Date.now(); + const isCE = isCustomElementName(name); + const tick = () => { + const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); + if (found) { + polling.delete(name); + onResolved(); + return; + } + if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { + console.warn( + "[dc-runtime] x-import: global", + JSON.stringify(name), + "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" + ); + return; + } + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + }; + setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); + } + function resolveGlobal(url, name) { + const isCE = isCustomElementName(name); + if (!url) { + if (isCE) { + if (customElements.get(name)) return name; + waitForGlobal(name); + return null; + } + const g2 = resolveDottedPath(window, name); + if (isRenderableType(g2)) return g2; + waitForGlobal(name); + return null; + } + const entry = cache.get(url); + if (!entry) return null; + if (isCE && customElements.get(name)) return name; + const g = entry.globals[name] ?? resolveDottedPath(window, name); + if (isRenderableType(g)) return g; + if (name.includes(".")) return null; + const key = url + "\0global\0" + name; + if (!reportedMissing.has(key)) { + reportedMissing.set(key, null); + if (isCE && !customElements.get(name)) { + console.warn( + "[dc-runtime] x-import:", + url, + "loaded but no custom element", + JSON.stringify(name), + "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." + ); + } + } + return name; + } + function getError(url, name) { + const entry = cache.get(url); + if (entry?.error) return entry.error; + return reportedMissing.get(url + "\0" + name) || null; + } + return { load, resolve: resolve2, resolveGlobal, getError }; + } + function isElementClass(g) { + try { + return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; + } catch { + return false; + } + } + + // src/atomics.ts + var ATOMIC_CSS = ( + // layout + ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" + ); + + // src/helmet.ts + var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; + var CANVAS_BG_LIGHT = "#f0eee6"; + var CANVAS_BG_DARK = "#2e2c26"; + function createHelmetManager(doc, isStreaming) { + const mounted = /* @__PURE__ */ new Set(); + const live = /* @__PURE__ */ new Map(); + let designDocMode = null; + let canvasStyleEl = null; + let appTheme = "light"; + try { + const ds = doc.documentElement.dataset.theme; + appTheme = ds === "dark" || ds === "light" ? ds : new URLSearchParams(doc.defaultView?.location.search ?? "").get( + "theme" + ) === "dark" ? "dark" : "light"; + } catch { + } + function applyCanvasBg() { + if (!canvasStyleEl) return; + const bg = appTheme === "dark" ? CANVAS_BG_DARK : CANVAS_BG_LIGHT; + canvasStyleEl.textContent = `html,body{background:${bg}}#dc-root>.sc-host{position:relative}`; + } + function postDesignMode(mode) { + if (window.parent === window) return; + try { + window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); + } catch { + } + } + function setDesignDocMode(mode) { + if (mode === designDocMode) return; + designDocMode = mode; + postDesignMode(mode); + if (mode === "canvas") { + doc.documentElement.setAttribute("data-dc-canvas", ""); + canvasStyleEl = doc.createElement("style"); + canvasStyleEl.setAttribute("data-dc-canvas", ""); + applyCanvasBg(); + doc.head.appendChild(canvasStyleEl); + } else { + doc.documentElement.removeAttribute("data-dc-canvas"); + canvasStyleEl?.remove(); + canvasStyleEl = null; + } + } + window.addEventListener("message", (e) => { + const type = e.data && e.data.type; + if (type === "__dc_theme") { + const t = e.data.theme; + if (t === "light" || t === "dark") { + appTheme = t; + applyCanvasBg(); + } + return; + } + if (!designDocMode || type !== "__dc_probe") return; + postDesignMode(designDocMode); + }); + function compile(node) { + const raw = [...node.children]; + const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; + if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { + mounted.add("__dc-atomics"); + const el = doc.createElement("style"); + el.id = "__dc-atomics"; + el.textContent = ATOMIC_CSS; + doc.head.appendChild(el); + } + return (_vals, ctx) => { + const name = ctx && ctx.__name || ""; + const streaming = !!(name && isStreaming(name)); + for (let i = 0; i < raw.length; i++) { + const child = raw[i]; + const tag = child.tagName; + const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; + if (tag === "SCRIPT") { + if (mayBePartial) continue; + const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); + if (mounted.has(key)) continue; + mounted.add(key); + const el = doc.createElement("script"); + for (const { name: an, value } of [...child.attributes]) + el.setAttribute(an, value); + if (child.textContent) el.textContent = child.textContent; + doc.head.appendChild(el); + } else if (tag === "LINK" || tag === "META") { + if (mayBePartial) continue; + const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); + if (mounted.has(key)) continue; + mounted.add(key); + if (tag === "LINK") { + const rel = (child.getAttribute("rel") || "").toLowerCase().split(/\s+/); + const href = (child.getAttribute("href") || "").trim(); + const res = window.__resources; + const pre = res && rel.includes("stylesheet") && !rel.includes("alternate") ? res[href] : void 0; + const blob = typeof pre === "string" && pre ? bundledBlob(pre) : null; + if (blob) { + const el = doc.createElement("style"); + if (child.hasAttribute("disabled")) { + el.setAttribute("media", "not all"); + } else if (child.getAttribute("media")) { + el.setAttribute("media", child.getAttribute("media")); + } + if (child.getAttribute("title")) + el.setAttribute("title", child.getAttribute("title")); + void blob.text().then((css) => { + el.textContent = css; + }); + doc.head.appendChild(el); + continue; + } + } + doc.head.appendChild(child.cloneNode(true)); + } else { + const key = name + "|" + i; + let el = live.get(key); + if (!el || el.tagName !== tag) { + if (el) el.remove(); + el = doc.createElement(tag.toLowerCase()); + live.set(key, el); + doc.head.appendChild(el); + } + for (const { name: an, value } of [...child.attributes]) { + if (el.getAttribute(an) !== value) el.setAttribute(an, value); + } + if (el.textContent !== child.textContent) + el.textContent = child.textContent; + } + } + return null; + }; + } + return { compile, setDesignDocMode }; + } + + // src/pseudo.ts + function createPseudoSheet(doc) { + let el = null; + const cache = /* @__PURE__ */ new Map(); + let n = 0; + return (pseudo, css) => { + const k = pseudo + "|" + css; + const hit = cache.get(k); + if (hit) return hit; + if (!el) { + el = doc.createElement("style"); + doc.head.appendChild(el); + } + const cls = "scp" + (n++).toString(36); + const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; + el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); + cache.set(k, cls); + return cls; + }; + } + + // src/registry.ts + function createRegistry() { + const entries = /* @__PURE__ */ Object.create(null); + function get(name) { + return entries[name] || (entries[name] = { + html: "", + tpl: null, + Logic: null, + jsStreaming: false, + htmlStreaming: false, + ver: 0, + subs: /* @__PURE__ */ new Set(), + fetched: false + }); + } + function bump(name) { + const r = get(name); + r.ver++; + for (const fn of r.subs) fn(); + } + return { + entries, + get, + bump, + bumpAll() { + for (const n in entries) bump(n); + } + }; + } + + // src/runtime.ts + var COMPONENT_DIR = "."; + function createRuntime(doc = document) { + const registry = createRegistry(); + const pseudoClass = createPseudoSheet(doc); + const helmet = createHelmetManager( + doc, + (name) => registry.get(name).htmlStreaming + ); + const external = createExternalModules(() => registry.bumpAll()); + const factory = createComponentFactory(registry, ensureFetched); + const host = { + component: (name) => factory.getDC(name), + placeholder: (props) => h(Placeholder, props), + helmet: (node) => helmet.compile(node), + loadExternal: (kind, url, after) => external.load(kind, url, after), + resolveExternal: (url, name) => external.resolve(url, name), + resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), + resolveExternalError: (url, name) => external.getError(url, name), + pseudoClass + }; + function ensureFetched(name) { + const r = registry.get(name); + if (r.fetched) return; + r.fetched = true; + const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; + const res = window.__resources; + const pre = res ? res[url] : void 0; + const target = typeof pre === "string" && pre ? pre : url; + const blob = bundledBlob(target); + (blob ? blob.text() : fetch(target).then((res2) => { + if (!res2.ok) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '" failed:', + url, + "returned", + res2.status, + "\u2014 the reference renders as an empty placeholder." + ); + return ""; + } + return res2.text(); + })).then((t) => { + if (!t) return; + const parsed = parseDcText(t); + if (!parsed) { + console.error( + '[dc-runtime] sibling fetch for "' + name + '":', + url, + "has no block \u2014 not a Design Component." + ); + return; + } + if (parsed.props) r.propsMeta = parsed.props; + if (parsed.preview) r.preview = parsed.preview; + if (parsed.template && !r.html) updateHtml(name, parsed.template); + if (parsed.js && !r.Logic) updateJs(name, parsed.js); + }).catch( + (e) => console.error( + '[dc-runtime] sibling fetch for "' + name + '" threw:', + url, + e + ) + ); + } + let rootName = null; + function updateHtml(name, html) { + const r = registry.get(name); + r.html = html; + if (name === rootName) { + const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; + if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); + } + try { + r.tpl = compileTemplate(html, host); + } catch (e) { + console.error("[dc-runtime] template compile FAILED for", name, e); + } + registry.bump(name); + } + function updateJs(name, src) { + const r = registry.get(name); + const seq = r.jsSeq = (r.jsSeq || 0) + 1; + try { + const Cls = evalDcLogic(src); + if (r.jsSeq !== seq) return; + if (typeof Cls !== "function") { + r.logicError = name + ".dc.html: + + + + + + + + + + +
+ + + + + +
+
+
+ + + ESC +
+
+
JUMP TO
+
+ + Tonight's Slate Β· Edge Board + ↡ +
+
+ + Ledger Β· settled history + ↡ +
+
+ + Streaks + ↡ +
+
PLAYERS
+
+ NJ + Nikola Jokić · C · DEN + A+ +
+
+ PIT + Paul Skenes Β· RHP Β· PIT + A +
+
+ MIN + Anthony Edwards Β· SG Β· MIN + A +
+
ACTIONS
+
+ + Generate share card + ↡ +
+
+ + Export /u public record + ↡ +
+ +
+
+ ↑ ↓ NAVIGATE Β· ↡ OPEN Β· ESC CLOSE + VYNDR TERMINAL +
+
+
+
+ + +
+
+
VYNDR
+
+
SPORTS INTELLIGENCE TERMINAL
+
+
+ +
+
+ 14 + EDGES +
+
+ +2.8% + AVG CLV +
+
+
+ + SYNC + --:--:-- +
+ +
$44.99/mo
+
+
+ +
+ + +
+
VISUAL SYSTEM Β· v2
+

The instrument, not the sportsbook.

+

Dense but ranked. Mostly still, then it reacts β€” a number pulses the instant a line moves, a card breathes when its game goes live. One hero per screen.

+
+ + +
+
+
+ TONIGHT'S SLATE + MULTI-LEAGUE Β· 47 GAMES Β· 312 PROPS GRADED +
+ RANKED BY EDGE +
+ +
+ +
+
+
+ EDGE BOARD + 14 LIVE +
+ SORTED Β· EDGE β–Ό +
+
+
+ # + READ + GRD + EDGE +
+ +
+ 01 +
Jokić o27.5 PTS
NBA Β· DEN @ MIN
+ A+ + +8.4% +
+
+ 02 +
Skenes o6.5 K
MLB Β· PIT @ CHC
+ A + +6.1% +
+
+ 03 +
Edwards o5.5 AST
LIVE Β· Q3 4:12 Β· DEN @ MIN
+ A + +5.2% +
+
+ 04 +
Judge o1.5 TB
MLB Β· NYY @ BOS
+ B + +3.4% +
+
+ 05 +
McDavid o1.5 PTS
NHL Β· EDM @ CGY
+ B + +3.0% +
+
+ 06 +
Mahomes o274.5 PYD
NFL Β· KC @ BUF
+ B + +2.6% +
+
+ 07 +
Betts o1.5 TB
MLB Β· LAD @ SD
+ C + +2.1% +
+
+ 08 +
Brunson o24.5 PTS
NBA Β· NYK @ BOS
+ C + +1.4% +
+
+ 09 +
Curry o4.5 3PM
NBA Β· GSW @ SAC
+ C + +0.4% +
+
+ 10 +
LaVine o22.5 PTS
NBA Β· CHI @ MIL
+ D + -1.2% +
+
+ + 37 GRADED READS BELOW + VIEW ALL β–Έ +
+
+ + +
+
+
+
+ DECLASSIFIED Β· TOP GRADE TONIGHT + CLV-VERIFIED +
+ +
+
+
A+
+
TIER
+
+
+
+ NJ +
+
Nikola Jokić
+
C Β· DEN Β· CONFIRMED
+
+
+
Over 27.5 Points
+
+ DK -114 + β–² + opened 25.5 +
+
+
+ + +
+
+
EDGE
+
+8.4%
+
+
+
L10 HIT
+
8/10
+
+
+
PROJ
+
30.8
+
+
+ + +
+
+ + + + + + + + + + +
+
+ HIT + MISS +
+
+ + +
+
+ + VYNDR INTELLIGENCE +
+

MIN plays the 4th-fastest pace vs. centers and concedes +3.1 rebounds to CONDUCTOR-type bigs. Line lagged the pace matchup by 2.0 β€” the steam confirms it.

+
+
+
+
+
+ + +
+
+ COMPONENT SYSTEM + THE PARTS EVERY SURFACE INHERITS +
+
+ +
+ + +
+
02 Β· STAT BLOCK
+
+
+ PTS / GAME Β· L15 + β–² +2.3 +
+
+ 28.6 +
+
#4 of 82 C
+
vs MIN allows 26.1
+
+
+
+ + + + + + + + + + + + L10 +
+
+
+ + +
+
03 Β· PLAYER IDENTITY
+
+
+
NJ
+ HEADSHOT SLOT +
+
+
+ Nikola Jokić + CONFIRMED +
+
C Β· #15 Β· Denver Nuggets
+
+ + + CONDUCTOR + + + + TORCH + +
+
+
+
+ + +
+
04 Β· LINE DISPLAY
+
+
+ Jokić o27.5 PTS + STEAM ▲ +
+
+
+ DraftKings +
+ -114 + BEST +
+
+
+ FanDuel +
+ -120 + β–² +
+
+
+ BetMGM +
+ -118 + β€” +
+
+
+
+ CLV vs CLOSE + +2.4% +
+
+
+ + +
+
05 Β· GRADE REVEAL Β· tiers
+
+
+
+
A
+
EDGE
+
+
+
B
+
LEAN
+
+
+
C
+
PASS
+
+
+
D
+
FADE
+
+
+
F
+
AVOID
+
+
+
+
+ GLOW RESERVED FOR A-TIER ONLY + EDGE COLORED BY SIGN +
+
+
+
+
+ + +
+
+ ARCHETYPE GLYPHS + FULL SYSTEM Β· 74 UNIQUE MARKS Β· 8 SPORTS +
+
+ +

One grid, one weight, one construction logic β€” filled base plus weighted stroke, so each mark reads as an emblem before the word. Colors deduped off signal-green so #00D4A0 stays uniquely "edge."

+

β†Ί marks names reused across sports β€” the vocabulary is a language: a WALL is the immovable last line in every sport.

+ + +
+ + +
+
+
THE BLEND Β· MULTI-ARCHETYPE FINGERPRINT
+

Players aren't one archetype β€” they carry a weighted blend. The mark system combines: primary dominant + supporting muted. This is the moat nobody copies.

+
+
+
+ TB +
+
Brandon Lowe
+
2B Β· Tampa Bay Rays
+
+
+
+
+
+ +
+
BOMBER
+
+
+
+ PRIMARY Β· BOMBER + 54% +
+
+
+ SUPPORTING Β· DRIVER + 46% +
+
+
+
+
+ +
+
DRIVER
+
+
+
+
+
+ + +
+
+ STREAKS + A BLOOMBERG ALERT, NOT A LOG LINE +
+
+ +
+ +
+
LA
+
+
+ Luka DončiΔ‡ + + + TORCH + +
+
o29.5 PTS Β· built vs bottom-10 defenses
+
+
+
+ 11 + STRAIGHT +
+ HIT RATE 100% Β· L11 +
+
+ +
+
TOR
+
+
+ Vladimir Guerrero Jr. + + + BOMBER + +
+
o1.5 TB Β· correlated w/ team total
+
+
+
+ 7 + STRAIGHT +
+ CAUTION Β· CORRELATION FLAG +
+
+
+ +
+ + +
+
+ PUBLIC RECORD + vyndr.io/u/edgehunter Β· THE VERIFIED WEAPON +
+
+ +
+ +
+
EH
+
+
+ @edgehunter + CLV-VERIFIED +
+
+ + + TORCH-HEAVY BETTOR + + 412 graded Β· since Mar '24 +
+
+ +
+ + +
+
+
HIT RATE
+
64.2%
+
+
+
UNITS
+
+38.6u
+
+
+
ROI
+
+11.4%
+
+
+
AVG CLV
+
+3.1%
+
+
+ + +
+
+ GRADE HISTORY Β· LAST 24 +
+ HIT + MISS +
+
+
+
+ + +
+
+
ARCHETYPE FINGERPRINT
+
+
+
+ TORCH + 32% +
+
+
+
+
+ BOMBER + 24% +
+
+
+
+
+ ALPHA + 18% +
+
+
+
+
+ CONDUCTOR + 14% +
+
+
+
+
+ +
+
RECENT SETTLED
+
+
+
Jokić o27.5 PTS
DEN Β· settled 30 Β· CLV +2.4%
+
AHIT
+
+
+
Judge o1.5 TB
NYY Β· settled 2 Β· CLV +1.1%
+
BHIT
+
+
+
Edwards o5.5 REB
MIN Β· settled 4 Β· CLV βˆ’0.8%
+
CMISS
+
+
+
Skenes o6.5 K
PIT Β· settled 8 Β· CLV +4.0%
+
A+HIT
+
+
+
+
+
+
+ + +
+
+ SHARE CARDS + STOP-THE-SCROLL Β· TIMELINE IS CUSTOMER #1 +
+
+ +
+ + +
+
THE SETTLE Β· 1080 Γ— 1350
+
+
+
+ VYNDR + JUL 11 +
+
+
DAILY SETTLE
+
+ +5.4u +
+
7–2 Β· 78% HIT Β· 9 GRADED
+
+
+
+ Jokić o27.5 PTS + A+HIT +
+
+ Skenes o6.5 K + AHIT +
+
+ Edwards o5.5 REB + CMISS +
+
+ Judge o1.5 TB + BHIT +
+
+
+ @edgehunter + CLV-VERIFIED +
+
+
+
+ + +
+
GRADE REVEAL Β· 1200 Γ— 630 OG
+
+
+
+ VYNDR + CLV-VERIFIED +
+
+
+ A+ + TIER +
+
+
Nikola Jokić
+
Over 27.5 Points
+
+
EDGE
+8.4%
+
L10
8/10
+
DK
βˆ’114
+
+
+
+
vyndr.io/u/edgehunter
+
+
+
+
+
+ + +
+
+ STYLE MATCHUP + COMBAT Β· STYLES MAKE FIGHTS Β· FIGHT-WEEK WEAPON +
+
+ +
+
+ UFC 312 Β· LIGHTWEIGHT Β· 5 RND MAIN + CLV-VERIFIED READ +
+ +
+ +
+
+
IM
+
Islam M.
26–1 Β· ORTHODOX Β· 70" REACH
+
+
GRAPPLER80%
+
STRIKER30%
+
+ βœ“COMBAT SAMBO + βœ“DAGESTAN WRESTLING + βœ“BJJ BROWN +
+
+ + +
+
VERDICT
+
VS
+
GRAPPLER EDGE
+
+ + +
+
+
AV
+
Alex V.
26–3 Β· ORTHODOX Β· 71.5" REACH
+
+
PRESSURE72%
+
TECHNICIAN44%
+
+ βœ“KICKBOXING + βœ“ELITE TDD 94% + ?WRESTLING BASE +
+
+
+ + +
+

Takedown defense is the whole fight. Islam's chain-wrestling beats a pure striker β€” but Volk's 94% TDD is the one variable that flips it.

+
+ RECENT-FORM OVERRIDE + Volk stuffed 11 of 12 TDs last 3 fights β†’ pushes this toward DECISION, not submission. +
+
+
+
MONEYLINE
A-Islam -230
+
DECISION
A+120
+
SUBMISSION
B++280
+
KO / TKO
C+650
+
O 2.5 RND
A--140
+
+
+
+ + +
+
+ SYSTEM SURFACES + LIVE GRADE-SHIFT Β· THE $44.99 TIER Β· EMPTY STATE +
+
+ +
+ + +
+
+
LIVE GRADE
+ Q3 4:12 Β· DEN @ MIN +
+
+
+
+
A+
+
LIVE TIER
+
+
+
Edwards o5.5 AST
+
GRADE FIRMED β–² 04:12
+
+ PACE β–² + USAGE β–² + MATCHUP βœ“ +
+
+
+ +
+
GRADE Β· BY GAME CLOCK+6.8%
+
+
+
+
+
+
+
+
+
+
+
TIPNOW
+
+
+
+ + +
+
+
+
+ VYNDR PRO + FREEPRO +
+
+ $44.99 + /mo +
+

Priced like a subscription. Performs like a $1M desk. Free gives you live scores + tonight's top 3 grades β€” Pro unlocks the whole board.

+
+ βœ“ Every graded read, every league, every night + βœ“ CLV-verified public record + share cards + βœ“ Live grade-shift + steam alerts + βœ“ Archetype fingerprints + combat style engine +
+ +
cancel anytime Β· no trial β€” the free tier is the trial
+
+
+
+ + +
+
+
+
TRANSMISSION QUIET
+
No reads cleared the A-tier threshold yet.
+

The engine graded 312 props tonight β€” none hit the edge floor. That's the system protecting you, not a bug. Grades refresh as lines move.

+
+ + +
+
+
+
+ + +
+
+ PITCHER IDENTITY + THE ARSENAL READ Β· ONE IDENTITY β†’ MANY PROPS +
+
+ +
+
+ +
+
+
PIT
+
+
Paul SkenesCONFIRMED
+
RHP Β· #30 Β· Pittsburgh Β· vs CHC
+
+
+
IDENTITY BLEND
+
+ ALPHA + PUNCHOUT +
+
+
APPROACH Β· TUNNELER
+

Splinker + slider tunnel off the 99mph 4-seam β€” hitters commit early and miss. Whiffs cluster the 3rd time through.

+
+
+ + +
+
+ PITCH + VELO + USE + WHIFF +
+
4-Seam99.132%26%
+
Splinker94.224%38%
+
Slider87.022%41%
+
Curve82.514%33%
+
+
+ +
+
STRIKEOUTS O6.5
A+6.1%
+
OUTS O16.5
A-+4.2%
+
EARNED RUNS U2.5
B++2.4%
+
1ST-INNING K
B+1.6%
+
+
+
+ + +
+
+ CORRELATION BUILDER + PARLAY MATH Β· THE STAKE-DOWN SIGNAL Β· TAP TO BUILD +
+
+ +
+ +
+
TONIGHT'S A/B READSTAP TO ADD
+
+
+ +
Jokić o27.5 PTS
NBA Β· DEN @ MIN
+ A+ + +105 +
+
+ +
Edwards o5.5 AST
NBA Β· DEN @ MIN
+ A + +130 +
+
+ +
Skenes o6.5 K
MLB Β· PIT @ CHC
+ A + -120 +
+
+ +
Judge o1.5 TB
MLB Β· NYY @ BOS
+ B + +140 +
+
+
+ + +
+
+ PARLAY SLIP + 2 LEGS +
+
+
+
SAME-GAME CORRELATION
+ +
+
+
COMBINED
+320
+
GRADE
B
+
VYNDR STAKE
0.4u
+
+
+
+
+ +
+
+ COMPONENTS Β· 74 GLYPHS Β· EDGE BOARD Β· /u Β· SHARE Β· COMBAT Β· LIVE GRADE Β· PRO Β· STATES + VYNDR VISUAL SYSTEM Β· v2 +
+
+ +
+
+
+ + + diff --git a/specs/wiring-data-train.md b/specs/wiring-data-train.md index 9ecaf2a..ed9ece9 100644 --- a/specs/wiring-data-train.md +++ b/specs/wiring-data-train.md @@ -79,7 +79,24 @@ Legend: **βœ… wire-able now** (data already in the system) Β· **πŸ”§ sourceable --- -## DECISIONS FOR THE FOUNDER (my honest defaults in **bold**) +## 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.) @@ -89,11 +106,12 @@ Legend: **βœ… wire-able now** (data already in the system) Β· **πŸ”§ sourceable --- -## PROPOSED WAVE SEQUENCE (ship on green, per step) -1. **Wave 1 β€” Step 1 trust bugs** (billing date + Wood nameKey collision + join-invariant test). Quick, independent. -2. **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.** -3. **Wave 3 β€” Step 3 outlook mode** (never-empty grid) + Step 4 wire-able-now surfaces (grade-shift timeline, consensus strip, Parlay Lab page). -4. **Wave 4 β€” Step 4 new feed** (pitcher arsenal via Savant) + /u house-profile mode (pending D2). -5. **Wave 5 β€” Step 5 combat** (spec first, then free v1 pending D5). +## 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). -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). +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. From 744eb688121d77184492187bd479d6c60999201f Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 03:35:09 -0400 Subject: [PATCH 03/15] Wave 2 build plan locked: sport-agnostic id thread-through (MLB MLBAM + NBA/WNBA ESPN id) Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/wiring-data-train.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/specs/wiring-data-train.md b/specs/wiring-data-train.md index ed9ece9..52b68a2 100644 --- a/specs/wiring-data-train.md +++ b/specs/wiring-data-train.md @@ -106,6 +106,18 @@ Weave in essentially everything from the mockup across all surfaces (see referen --- +## 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. From b6787af1913a2afc47e434cf82805341d170b2ea Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 03:48:58 -0400 Subject: [PATCH 04/15] Wave 1: kill three trust bugs (billing renewal + namesake collision + Desk copy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIX 1 β€” Honest billing renewal render. VYNDR tiers are monthly, so a `subscription_end` far in the future (the manually-seeded "RENEWS 6/9/2036" founder row) is a comped/lifetime/seed value, not a renewal. New web/src/lib/billingDisplay.js `classifyRenewal()` β†’ date | none | lapsed | unknown (strict Date.parse guard, MONTHLY_RENEWAL_MAX_DAYS=60). Profile page renders the classified label for both the "Renews" stat and the cancel-scheduled "Access ends" line β€” no raw far-future date. No DB row mutated. FIX 2 β€” MLB namesake collision (James Wood β†’ "Chicago Cubs"). searchPlayer now collects ALL exact-nameKey matches instead of first-`.find`; a β‰₯2 collision resolves ONLY via a confident teamHint (the prop's game participants, matched against the cached /teams list with ESPN↔statsapi abbr reconciliation), else refuses (null) β€” never guesses. The hint threads getPlayerStats β†’ resolvePlayerStats β†’ snapshotService (built from each prop's home/away team). Join invariant: a single-exact player whose team isn't in the hinted game has its team DROPPED (null), so streaks/rosterlogs never tag a foreign team. Full teamHint recovery shipped (not just the refuse fallback). FIX 3 β€” DeskShowcase headline "A $1M terminal." β†’ deadpan value-showing copy "Every grade, every alt line, live." Prices ($44.99 / $34.99) unchanged. Tests: billingDisplay.test.js (7), mlbNamesakeResolve.test.js (12, disambiguation + join invariant + pure helpers), ds5PricingStates updated to assert the new headline and no "$1M". Full suite green (237 suites / 2863 tests); web `next build` exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/adapters/mlbStatsAdapter.js | 145 +++++++++++++++++++---- src/services/playerIntelService.js | 5 +- src/services/snapshotService.js | 15 ++- tests/unit/billingDisplay.test.js | 48 ++++++++ tests/unit/ds5PricingStates.test.js | 8 +- tests/unit/mlbNamesakeResolve.test.js | 141 ++++++++++++++++++++++ web/src/app/pricing/DeskShowcase.tsx | 2 +- web/src/app/pricing/page.tsx | 2 +- web/src/app/profile/page.tsx | 24 +++- web/src/components/Pricing.tsx | 2 +- web/src/lib/billingDisplay.js | 58 +++++++++ 11 files changed, 421 insertions(+), 29 deletions(-) create mode 100644 tests/unit/billingDisplay.test.js create mode 100644 tests/unit/mlbNamesakeResolve.test.js create mode 100644 web/src/lib/billingDisplay.js diff --git a/src/services/adapters/mlbStatsAdapter.js b/src/services/adapters/mlbStatsAdapter.js index 200b399..ba0bcee 100644 --- a/src/services/adapters/mlbStatsAdapter.js +++ b/src/services/adapters/mlbStatsAdapter.js @@ -183,31 +183,132 @@ async function searchPlayers(query, opts = {}) { return matchPlayers(people, query, opts.limit || 12); } -async function searchPlayer(name, season = DEFAULT_SEASON) { +// ── Namesake disambiguation (Wave 1 Β· trust bug) ───────────────────────────── +// Two different players can share an EXACT nameKey ("James Wood" β€” the Nationals +// star + a Cubs-affiliate namesake). Taking the first `.find` match silently +// tagged the wrong team β†’ wrong opponents β†’ "built vs AL East" fabrication in +// streaks/rosterlogs. Doctrine: NEVER guess among namesakes. Resolve only with a +// confident team hint (the prop's game participants); otherwise refuse (null). + +// The odds feed sends ESPN-style abbrs; statsapi uses its own for a few clubs. +// Canonicalize both sides so AZ↔ARI, CHW↔CWS, WSN↔WSH, … compare equal. +const ABBR_ALIAS = Object.freeze({ + AZ: 'ARI', ARI: 'ARI', + CHW: 'CWS', CWS: 'CWS', + WSN: 'WSH', WSH: 'WSH', + SDP: 'SD', SD: 'SD', + SFG: 'SF', SF: 'SF', + TBR: 'TB', TB: 'TB', + KCR: 'KC', KC: 'KC', +}); +function canonAbbr(a) { + const u = String(a || '').toUpperCase().trim(); + return ABBR_ALIAS[u] || u; +} +function teamNorm(s) { + return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +} + +/** + * Does a resolved team record (`{ id, name }`, from a candidate's currentTeam) + * match ANY identifier in the prop's team hint? A hint entry may be an abbr + * ("WSH") OR a full/partial team name ("Washington Nationals" / "Nationals"). + * teams = the cached statsapi `/teams` list ([{ id, abbr, name }]) used to turn + * an abbr hint into a team id. + */ +function teamRecordMatchesHint(team, teamHint, teams) { + if (!team || !Array.isArray(teamHint) || teamHint.length === 0) return false; + const tId = team.id; + const tName = teamNorm(team.name); + for (const h of teamHint) { + if (!h) continue; + // 1) hint as a name (equal / either-contains β€” handles "Nationals" vs full) + const hn = teamNorm(h); + if (hn && tName && (hn === tName || tName.includes(hn) || hn.includes(tName))) return true; + // 2) hint as an abbr β†’ resolve to a team id via the teams list, compare ids + const rec = (teams || []).find((t) => canonAbbr(t.abbr) === canonAbbr(h)); + if (rec && tId != null && rec.id === tId) return true; + } + return false; +} + +/** Among namesake candidates, return the SINGLE one whose currentTeam matches + * the hint, else null (2+ or 0 matches β†’ refuse; never guess). */ +function disambiguateByHint(candidates, teamHint, teams) { + if (!Array.isArray(teamHint) || teamHint.length === 0) return null; + const matches = (candidates || []).filter((p) => + p.currentTeam && teamRecordMatchesHint({ id: p.currentTeam.id, name: p.currentTeam.name }, teamHint, teams)); + return matches.length === 1 ? matches[0] : null; +} + +/** S59 fallback for the NO-exact-match case: a unique last-name + first-initial + * hit, else null. A missing profile beats another player's log. */ +function lastNameInitialFallback(people, targetKey) { + const parts = targetKey.split(' '); + const first = parts[0] || ''; + const last = parts[parts.length - 1] || ''; + if (!(first && last && first !== last)) return null; + const cands = (people || []).filter((p) => { + const k = nameKey(p.fullName).split(' '); + return k[k.length - 1] === last && k[0] && k[0][0] === first[0]; + }); + return cands.length === 1 ? cands[0] : null; +} + +/** + * Resolve a name β†’ statsapi person. `opts.teamHint` (array of the prop's game + * team identifiers) disambiguates namesakes AND enforces the join invariant: + * when a hint is present but the resolved player's team is NOT a participant of + * the prop's game, the team is DROPPED (returned null) rather than tagging a + * foreign team downstream. `opts.people`/`opts.teams` inject fixtures for tests. + */ +async function searchPlayer(name, season = DEFAULT_SEASON, opts = {}) { const targetKey = nameKey(name); if (!targetKey) return null; - const url = `${BASE}/sports/1/players?season=${season}`; - const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); - const people = (data && Array.isArray(data.people)) ? data.people : []; - let hit = people.find((p) => nameKey(p.fullName) === targetKey); - if (!hit) { - const parts = targetKey.split(' '); - const first = parts[0] || ''; - const last = parts[parts.length - 1] || ''; - if (first && last && first !== last) { - const cands = people.filter((p) => { - const k = nameKey(p.fullName).split(' '); - return k[k.length - 1] === last && k[0] && k[0][0] === first[0]; - }); - if (cands.length === 1) hit = cands[0]; // unique or nothing β€” never guess + + let people = opts.people; + if (!Array.isArray(people)) { + const url = `${BASE}/sports/1/players?season=${season}`; + const data = await fetchWithCache(url, `mlbstats:players:${season}`, 24 * 3600); + people = (data && Array.isArray(data.people)) ? data.people : []; + } + + const teamHint = Array.isArray(opts.teamHint) && opts.teamHint.length ? opts.teamHint : null; + let teams = Array.isArray(opts.teams) ? opts.teams : null; + const ensureTeams = async () => { + if (teams) return teams; + try { teams = await getTeams(season); } catch { teams = []; } + return teams; + }; + + const exact = people.filter((p) => nameKey(p.fullName) === targetKey); + let hit = null; + let teamConfirmed = true; // stays true when there's no hint to check against + + if (exact.length === 1) { + hit = exact[0]; + if (teamHint && hit.currentTeam) { + teamConfirmed = teamRecordMatchesHint( + { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); + } + } else if (exact.length >= 2) { + // Namesake collision β€” resolve ONLY with a confident hint, else refuse. + hit = teamHint ? disambiguateByHint(exact, teamHint, await ensureTeams()) : null; + // a hit here is team-confirmed by construction. + } else { + hit = lastNameInitialFallback(people, targetKey); + if (hit && teamHint && hit.currentTeam) { + teamConfirmed = teamRecordMatchesHint( + { id: hit.currentTeam.id, name: hit.currentTeam.name }, teamHint, await ensureTeams()); } } + if (!hit) return null; return { id: hit.id, fullName: hit.fullName ?? name, - team: hit.currentTeam?.name ?? null, - teamId: hit.currentTeam?.id ?? null, + team: teamConfirmed ? (hit.currentTeam?.name ?? null) : null, + teamId: teamConfirmed ? (hit.currentTeam?.id ?? null) : null, position: hit.primaryPosition?.abbreviation ?? null, }; } @@ -219,9 +320,9 @@ async function searchPlayer(name, season = DEFAULT_SEASON) { * last10 } β€” `season` is the raw MLB stat object, mapped by the caller. Returns * { found: false } on any miss/failure (never throws). */ -async function getPlayerStats(name, season = DEFAULT_SEASON) { +async function getPlayerStats(name, season = DEFAULT_SEASON, opts = {}) { try { - const person = await searchPlayer(name, season); + const person = await searchPlayer(name, season, opts); if (!person) return { found: false }; const group = person.position === 'P' ? 'pitching' : 'hitting'; const [seasonStat, log] = await Promise.all([ @@ -293,5 +394,9 @@ module.exports = { getTeams, resolveTeam, getTeamRoster, - __internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON }, + __internals: { + BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, + // Wave 1 β€” pure namesake-disambiguation helpers (unit-tested with fixtures). + canonAbbr, teamNorm, teamRecordMatchesHint, disambiguateByHint, lastNameInitialFallback, + }, }; diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index 64a9ba8..5b71a68 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -116,7 +116,10 @@ async function resolvePlayerStats(name, sport, opts = {}) { try { if (sp === 'mlb') { const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter'); - const res = await mlb.getPlayerStats(name); + // Wave 1 β€” thread the prop's game team hint so a namesake collision + // (two "James Wood") resolves to the RIGHT player, and a team that isn't + // a participant of the prop's game is dropped (never a foreign tag). + const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint }); if (!res || !res.found) return { found: false }; const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season); // Session 48 β€” real VYNDR INTELLIGENCE for the player profile: usage (AB/G) diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index 4479b43..b65b1c9 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -298,6 +298,18 @@ async function runSnapshot(sport, opts = {}) { // (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns // and the slate join guard (a prop only attaches to its own game). const teamByPlayer = {}; + // Wave 1 (trust bug) β€” the prop's game participants become the resolve's + // teamHint: it disambiguates namesake collisions (two "James Wood") and, when + // the resolved player's real team isn't in the prop's game, the resolver drops + // the team rather than tag a foreign one (the streaks/rosterlogs JOIN + // INVARIANT, mirroring the S59 slate guard). Keyed by the normalized name. + const teamHintByPlayer = {}; + for (const p of props || []) { + const k = norm(p.player); + if (!k || teamHintByPlayer[k]) continue; + const hint = [p.home_team, p.away_team].filter(Boolean); + if (hint.length) teamHintByPlayer[k] = hint; + } // Session 60 (night2/B) β€” THE STREAKS PRODUCER. The aggregator (streaks + // hot lists) starved because its data producers were all external and // unarmed (tank01-prefetch via n8n, the offline Python grading flow). @@ -308,7 +320,8 @@ async function runSnapshot(sport, opts = {}) { const logEntries = []; await mapLimit(players, STATS_CONCURRENCY, async (player) => { try { - const stats = await deps.resolveStats(player, sp); + const teamHint = teamHintByPlayer[norm(player)] || null; + const stats = await deps.resolveStats(player, sp, teamHint ? { teamHint } : {}); if (stats && stats.found) { const c = deps.classify(sp, stats.classifierInput || {}); archByPlayer[player] = c.primary ? c.primary.name : null; diff --git a/tests/unit/billingDisplay.test.js b/tests/unit/billingDisplay.test.js new file mode 100644 index 0000000..94dbbc9 --- /dev/null +++ b/tests/unit/billingDisplay.test.js @@ -0,0 +1,48 @@ +// Wave 1 trust bug β€” honest renewal render. classifyRenewal must never let a +// far-future / comped `subscription_end` (the "RENEWS 6/9/2036" lie) render as +// a real monthly renewal, and must fail closed to `unknown` on bad input. + +const { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS } = require('../../web/src/lib/billingDisplay'); + +const NOW = Date.parse('2026-07-13T00:00:00.000Z'); +const DAY = 86_400_000; + +describe('classifyRenewal β€” honest billing render', () => { + test('MONTHLY_RENEWAL_MAX_DAYS is 60', () => { + expect(MONTHLY_RENEWAL_MAX_DAYS).toBe(60); + }); + + test('the 2036 comped/seed row is NOT a renewal β†’ none', () => { + const r = classifyRenewal('2036-06-09T00:00:00.000Z', NOW); + expect(r.kind).toBe('none'); + expect(r.iso).toBeUndefined(); + }); + + test('a plausible monthly next-bill (now + 30d) β†’ date, carries iso', () => { + const r = classifyRenewal(new Date(NOW + 30 * DAY).toISOString(), NOW); + expect(r.kind).toBe('date'); + expect(typeof r.iso).toBe('string'); + expect(Date.parse(r.iso)).toBe(NOW + 30 * DAY); + }); + + test('absent value (null / undefined / empty) β†’ unknown', () => { + expect(classifyRenewal(null, NOW).kind).toBe('unknown'); + expect(classifyRenewal(undefined, NOW).kind).toBe('unknown'); + expect(classifyRenewal('', NOW).kind).toBe('unknown'); + }); + + test('a renewal well in the past β†’ lapsed', () => { + const r = classifyRenewal(new Date(NOW - 10 * DAY).toISOString(), NOW); + expect(r.kind).toBe('lapsed'); + }); + + test('malformed date string β†’ unknown (never coerced to an epoch date)', () => { + expect(classifyRenewal('not a date', NOW).kind).toBe('unknown'); + expect(classifyRenewal('N/A', NOW).kind).toBe('unknown'); + }); + + test('exactly at the 60-day boundary still reads as a date; just beyond β†’ none', () => { + expect(classifyRenewal(new Date(NOW + 60 * DAY).toISOString(), NOW).kind).toBe('date'); + expect(classifyRenewal(new Date(NOW + 61 * DAY).toISOString(), NOW).kind).toBe('none'); + }); +}); diff --git a/tests/unit/ds5PricingStates.test.js b/tests/unit/ds5PricingStates.test.js index 51ace72..6c2f934 100644 --- a/tests/unit/ds5PricingStates.test.js +++ b/tests/unit/ds5PricingStates.test.js @@ -47,10 +47,14 @@ describe('Pricing β€” Desk is the hero, real prices, single primary CTA', () => expect(pricing).not.toContain("originalPrice: '$49.99'"); }); - test('the "$1M terminal Β· $44.99" story leads, above the grid, with a real feature ladder', () => { + test('the Desk story leads with deadpan value-showing copy (no "$1M" brag), above the grid, with a real feature ladder', () => { expect(page).toContain('import DeskShowcase'); expect(page).toContain(' { + test('(a) a single unambiguous name resolves (Aaron Judge β†’ Yankees)', async () => { + const r = await mlb.searchPlayer('Aaron Judge', 2026, { people: [JUDGE], teams: TEAMS }); + expect(r).not.toBeNull(); + expect(r.id).toBe(592450); + expect(r.team).toBe('New York Yankees'); + }); + + test('(b) two "James Wood" with NO hint β†’ null (NEVER the Cubs one)', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_NATIONALS, WOOD_CUBS], teams: TEAMS }); + expect(r).toBeNull(); // refuse β€” honest absent beats wrong + }); + + test('(c) two "James Wood" + a Nationals-game hint β†’ the Nationals Wood', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { + people: [WOOD_CUBS, WOOD_NATIONALS], // Cubs first β€” the old bug picked this + teams: TEAMS, + teamHint: ['WSH', 'MIA'], // Nationals @ Marlins + }); + expect(r).not.toBeNull(); + expect(r.id).toBe(691026); + expect(r.team).toBe('Washington Nationals'); + expect(r.team).not.toBe('Chicago Cubs'); + }); + + test('(c2) a full-team-name hint disambiguates too', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { + people: [WOOD_CUBS, WOOD_NATIONALS], + teams: TEAMS, + teamHint: ['Washington Nationals', 'Miami Marlins'], + }); + expect(r.id).toBe(691026); + }); + + test('(c3) two namesakes + a hint matching NEITHER β†’ null (still refuse)', async () => { + const r = await mlb.searchPlayer('James Wood', 2026, { + people: [WOOD_NATIONALS, WOOD_CUBS], + teams: TEAMS, + teamHint: ['LAD', 'SD'], + }); + expect(r).toBeNull(); + }); +}); + +// JOIN INVARIANT (streaks/rosterlogs) β€” a resolved player's team must be a +// participant of the prop's game; on mismatch the team is DROPPED, never a +// foreign tag. This is the mechanism snapshotService relies on before it writes +// teamByPlayer + rosterlogs opponents. +describe('searchPlayer β€” team join invariant (drop, never tag foreign)', () => { + test('a single player whose team is NOT in the hinted game β†’ team dropped to null', async () => { + const r = await mlb.searchPlayer('Juan Soto', 2026, { + people: [SOTO_METS], + teams: TEAMS, + teamHint: ['LAD', 'SD'], // Soto (Mets) is in neither β†’ cannot confirm join + }); + expect(r).not.toBeNull(); // still the right player by name + expect(r.id).toBe(665742); + expect(r.team).toBeNull(); // but his team is not tagged onto a foreign game + expect(r.teamId).toBeNull(); + }); + + test('a single player whose team IS in the hinted game keeps its team', async () => { + const r = await mlb.searchPlayer('Juan Soto', 2026, { + people: [SOTO_METS], + teams: TEAMS, + teamHint: ['NYM', 'LAD'], // Mets @ Dodgers β†’ confirmed + }); + expect(r.team).toBe('New York Mets'); + }); + + test('with NO hint, a single player keeps its authoritative team', async () => { + const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS }); + expect(r.team).toBe('New York Mets'); + }); +}); + +describe('pure helpers β€” abbr reconciliation + hint matching', () => { + test('ESPN↔statsapi abbr aliases canonicalize (AZ↔ARI, CHW↔CWS)', () => { + expect(canonAbbr('AZ')).toBe(canonAbbr('ARI')); + expect(canonAbbr('CHW')).toBe(canonAbbr('CWS')); + expect(canonAbbr('nyy')).toBe('NYY'); + }); + + test('teamRecordMatchesHint matches by abbr via the teams list', () => { + expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['WSH'], TEAMS)).toBe(true); + expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['CHC'], TEAMS)).toBe(false); + }); + + test('teamRecordMatchesHint matches by partial name', () => { + expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['Nationals'], TEAMS)).toBe(true); + }); + + test('disambiguateByHint refuses when 2 candidates share the hinted team', () => { + const dupe = [WOOD_NATIONALS, { ...WOOD_CUBS, currentTeam: { id: 120, name: 'Washington Nationals' } }]; + expect(disambiguateByHint(dupe, ['WSH'], TEAMS)).toBeNull(); + }); +}); diff --git a/web/src/app/pricing/DeskShowcase.tsx b/web/src/app/pricing/DeskShowcase.tsx index 8f8cb0b..9e9e236 100644 --- a/web/src/app/pricing/DeskShowcase.tsx +++ b/web/src/app/pricing/DeskShowcase.tsx @@ -36,7 +36,7 @@ export default function DeskShowcase() { THE DESK Β· FLAGSHIP

- A $1M terminal.{' '} + Every grade, every alt line, live.{' '} $44.99 /mo.

diff --git a/web/src/app/pricing/page.tsx b/web/src/app/pricing/page.tsx index 2ee04f5..a6bae28 100644 --- a/web/src/app/pricing/page.tsx +++ b/web/src/app/pricing/page.tsx @@ -36,7 +36,7 @@ export const metadata: Metadata = { export default function PricingPage() { return (
- {/* DS5 (#8) β€” Desk is the hero. The "$1M terminal" story leads, above the + {/* DS5 (#8) β€” Desk is the hero. The value-showing story leads, above the grid, so the premium tier stops being an afterthought. */} diff --git a/web/src/app/profile/page.tsx b/web/src/app/profile/page.tsx index 2ec8da8..fbe3505 100644 --- a/web/src/app/profile/page.tsx +++ b/web/src/app/profile/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { currentAccessToken } from '@/lib/authToken'; +import { classifyRenewal } from '@/lib/billingDisplay'; interface FullProfile { id: string; @@ -125,7 +126,7 @@ export default function ProfilePage() { {tier !== 'free' && (
- +
)} @@ -170,7 +171,7 @@ export default function ProfilePage() {

Cancellation scheduled. Access ends{' '} - {profile.subscription_end ? new Date(profile.subscription_end).toLocaleDateString() : 'at period end'}. + {accessEndsLabel(profile.subscription_end)}.

)} @@ -199,6 +200,25 @@ function Stat({ label, value, tone }: { label: string; value: string; tone?: 'go ); } +// Renewal render is guarded (Wave 1 trust bug) β€” VYNDR tiers are monthly, so a +// `subscription_end` far in the future is a comped/seed value, not a renewal. +// classifyRenewal decides; we never print a raw date the cadence can't justify. +function renewalLabel(subscriptionEnd: string | null): string { + const r = classifyRenewal(subscriptionEnd); + if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString(); + if (r.kind === 'none') return 'No scheduled renewal'; + if (r.kind === 'lapsed') return 'Lapsed'; + return 'β€”'; +} + +// The cancel-scheduled line only shows a real near date; anything else falls to +// the honest "at period end" (never a fabricated 2036 access-end). +function accessEndsLabel(subscriptionEnd: string | null): string { + const r = classifyRenewal(subscriptionEnd); + if (r.kind === 'date' && r.iso) return new Date(r.iso).toLocaleDateString(); + return 'at period end'; +} + function tierColor(tier: string): string { if (tier === 'desk') return 'var(--grade-a)'; if (tier === 'analyst') return 'var(--grade-b)'; diff --git a/web/src/components/Pricing.tsx b/web/src/components/Pricing.tsx index 6287e00..ec9e84a 100644 --- a/web/src/components/Pricing.tsx +++ b/web/src/components/Pricing.tsx @@ -89,7 +89,7 @@ const TIERS: TierConfig[] = [ highlight: false, }, { - // DS5 (Part 6, #8) β€” Desk is THE hero tier. It carries the "$1M terminal" + // DS5 (Part 6, #8) β€” Desk is THE hero tier. It carries the value-showing // story and the single primary CTA on the grid (color contract #9: never // two competing green CTAs). $44.99 regular, $34.99 for founders. id: 'desk', diff --git a/web/src/lib/billingDisplay.js b/web/src/lib/billingDisplay.js new file mode 100644 index 0000000..c0f8452 --- /dev/null +++ b/web/src/lib/billingDisplay.js @@ -0,0 +1,58 @@ +/* ============================================================ + VYNDR β€” BILLING DISPLAY (honest renewal render). + Plain CommonJS so .tsx components import it AND the Jest suite + requires it directly (same pattern as colorContract.js / checkout.js). + + The lie this kills: the profile page rendered `subscription_end` + verbatim, so a manually-seeded / comped founder row reading + "6/9/2036" showed as a real renewal. VYNDR tiers are MONTHLY only + (no annual), so any date more than ~60 days out is NOT a plausible + monthly next-bill β€” it is a comped / lifetime / seed value and must + NOT be rendered as a renewal date. + + Doctrine: absent-but-honest beats wrong-but-full. Never render a + renewal date the billing cadence can't justify. Strict parsing + (the `Number(null) === 0` class of bug) β€” an unparseable value is + `unknown`, never coerced to an epoch date. + ============================================================ */ + +// A monthly plan renews ~30 days out; allow slack for proration / grace, +// but a value beyond this many days out cannot be a monthly renewal. +const MONTHLY_RENEWAL_MAX_DAYS = 60; + +// A renewal more than this many days in the PAST is a lapsed subscription +// (a small grace window absorbs clock skew / just-past renewals). +const LAPSED_GRACE_DAYS = 2; + +const DAY_MS = 86_400_000; + +/** + * classifyRenewal(subscriptionEnd, nowMs) β†’ { kind, iso? } + * + * kind ∈ + * 'date' β€” a plausible monthly renewal (0..~60d out). Carries `iso` + * (the parsed timestamp) for the caller to localize. + * 'none' β€” more than ~60d out β†’ comped / lifetime / seed value; there + * is no scheduled monthly renewal to show. + * 'lapsed' β€” more than 2d past β†’ the subscription window has ended. + * 'unknown' β€” absent / empty / unparseable β†’ show an em dash. + * + * @param {string|number|null|undefined} subscriptionEnd provider-asserted end. + * @param {number} [nowMs] current epoch ms (injectable for tests). + */ +function classifyRenewal(subscriptionEnd, nowMs = Date.now()) { + if (subscriptionEnd === null || subscriptionEnd === undefined || subscriptionEnd === '') { + return { kind: 'unknown' }; + } + const t = Date.parse(subscriptionEnd); + if (Number.isNaN(t)) return { kind: 'unknown' }; + + const now = Number.isFinite(nowMs) ? nowMs : Date.now(); + const diffDays = (t - now) / DAY_MS; + + if (diffDays < -LAPSED_GRACE_DAYS) return { kind: 'lapsed' }; + if (diffDays > MONTHLY_RENEWAL_MAX_DAYS) return { kind: 'none' }; + return { kind: 'date', iso: new Date(t).toISOString() }; +} + +module.exports = { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS }; From 7d6dbc6cf35eac56a0c3f1880089838791598cf3 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 11:44:15 -0400 Subject: [PATCH 05/15] Wave 2B: sportsbook wordmarks + team-logo coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MISSION 1 β€” real sportsbook wordmarks (kills lowercase "betmgm"): - books.js: add the 6 missing ALLOWED_BOOKS keys (fanatics/bet365/ hardrockbet/betrivers/pointsbet/pinnacle) with real brand names + colors β€” no live book falls to neutral gray. Add `slug` fields + bookSlug()/hasBookSvg() + BUNDLED_BOOK_SVGS. - Bundle 8 self-authored styled-text wordmark SVGs under web/public/books/{slug}.svg (draftkings/fanduel/betmgm/caesars/ bet365/pinnacle/hardrockbet/betrivers). NOT copied trademarked logo glyphs β€” the book's NAME in brand weight+color; official press-kit art can drop into the same paths with zero code change. - BookWordmark: render the local SVG when bundled, else the brand-color styled-text fallback (never a broken image; never a lowercase key). - Import BookWordmark into the ledger row (page.tsx:368) + the identical public-profile row, replacing bare {row.book} text. vyndr/GameCard line-grid book cell now proper-cases via bookInfo().name (keeps the preferred-book green highlight). MISSION 2 β€” team-logo coverage gaps: - teamMeta.js: add ESPN-schedule ball-sport abbr aliases the feed emits that fell to monograms β€” SAβ†’SAS, NYβ†’NYK, WSHβ†’WAS, BRKβ†’BKN (NBA), CONNβ†’CON (WNBA). Real-abbr-first lookup means MLB WSH (Nationals) + WNBA NY (Liberty) still resolve directly; NY in MLB stays null. Tests: new tests/unit/bookWordmark.test.js (all 10 ALLOWED_BOOKS resolve to a real brand+non-gray color; 8 bundled SVGs exist; BookWordmark SVG-first + no-lowercase-leak; ledger/profile import + use BookWordmark). entityLayer.test.js extended for the new aliases. Full suite green (239 suites / 2891 tests); next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/bookWordmark.test.js | 114 ++++++++++++++++++++++ tests/unit/entityLayer.test.js | 19 ++++ web/public/books/bet365.svg | 1 + web/public/books/betmgm.svg | 1 + web/public/books/betrivers.svg | 1 + web/public/books/caesars.svg | 1 + web/public/books/draftkings.svg | 1 + web/public/books/fanduel.svg | 1 + web/public/books/hardrockbet.svg | 1 + web/public/books/pinnacle.svg | 1 + web/src/app/ledger/page.tsx | 4 +- web/src/app/u/[handle]/PublicProfile.tsx | 3 +- web/src/components/vyndr/BookWordmark.tsx | 25 +++-- web/src/components/vyndr/GameCard.tsx | 4 +- web/src/lib/books.js | 70 ++++++++++--- web/src/lib/teamMeta.js | 11 ++- 16 files changed, 231 insertions(+), 27 deletions(-) create mode 100644 tests/unit/bookWordmark.test.js create mode 100644 web/public/books/bet365.svg create mode 100644 web/public/books/betmgm.svg create mode 100644 web/public/books/betrivers.svg create mode 100644 web/public/books/caesars.svg create mode 100644 web/public/books/draftkings.svg create mode 100644 web/public/books/fanduel.svg create mode 100644 web/public/books/hardrockbet.svg create mode 100644 web/public/books/pinnacle.svg diff --git a/tests/unit/bookWordmark.test.js b/tests/unit/bookWordmark.test.js new file mode 100644 index 0000000..0c6b9e4 --- /dev/null +++ b/tests/unit/bookWordmark.test.js @@ -0,0 +1,114 @@ +// Wave 2B (data train) β€” sportsbook wordmarks. Every book the odds feed emits +// resolves to a real brand (name + non-gray color); the ~8 major books render a +// bundled local wordmark SVG; the ledger row renders BookWordmark, never bare +// lowercase `row.book`. The SVGs are self-authored styled text wordmarks (not +// copied trademarked logos), swappable for official press-kit art later. + +const fs = require('fs'); +const path = require('path'); +const { bookInfo, bookSlug, hasBookSvg, BUNDLED_BOOK_SVGS } = require('../../web/src/lib/books'); + +// The exact keys oddsNormalizer.ALLOWED_BOOKS emits into the pipeline. +const ALLOWED_BOOKS = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'fanatics', 'bet365', 'hardrockbet', 'pointsbet', 'betrivers', 'pinnacle']; +const DEFAULT_FG = '#B8BCC8'; + +const REPO = path.resolve(__dirname, '../..'); +const read = (p) => fs.readFileSync(path.join(REPO, p), 'utf8'); + +describe('bookInfo β€” every ALLOWED_BOOK is a real brand (no neutral-gray fall)', () => { + test.each(ALLOWED_BOOKS)('%s resolves to a real brand name + color', (key) => { + const b = bookInfo(key); + // name must be a real brand, not the raw uppercased key echoed back + expect(b.name).toBeTruthy(); + expect(b.name.toUpperCase()).not.toBe(key.toUpperCase()); + // color must not be the neutral-gray default + expect(b.fg).toBeTruthy(); + expect(b.fg.toLowerCase()).not.toBe(DEFAULT_FG.toLowerCase()); + // never a bare lowercase feed key in the display name + expect(b.name).not.toBe(key); + }); + + test('the 6 previously-missing keys now resolve (were gray)', () => { + expect(bookInfo('fanatics').name).toBe('Fanatics'); + expect(bookInfo('bet365').name).toBe('bet365'); + expect(bookInfo('hardrockbet').name).toBe('Hard Rock'); + expect(bookInfo('betrivers').name).toBe('BetRivers'); + expect(bookInfo('pointsbet').name).toBe('PointsBet'); + expect(bookInfo('pinnacle').name).toBe('Pinnacle'); + }); + + test('case-insensitive: DraftKings / DK / draftkings all resolve the same', () => { + expect(bookInfo('DraftKings').name).toBe('DraftKings'); + expect(bookInfo('DK').name).toBe('DraftKings'); + expect(bookInfo('draftkings').name).toBe('DraftKings'); + }); +}); + +describe('bundled book wordmark SVGs', () => { + const EXPECTED = ['draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers']; + + test('BUNDLED_BOOK_SVGS is exactly the 8 major books', () => { + expect([...BUNDLED_BOOK_SVGS].sort()).toEqual([...EXPECTED].sort()); + }); + + test.each(EXPECTED)('web/public/books/%s.svg exists and is a real ', (slug) => { + const svg = read(`web/public/books/${slug}.svg`); + expect(svg).toMatch(//); + }); + + test('bookSlug resolves feed keys + codes to the bundled slug', () => { + expect(bookSlug('draftkings')).toBe('draftkings'); + expect(bookSlug('DK')).toBe('draftkings'); + expect(bookSlug('hardrockbet')).toBe('hardrockbet'); + expect(hasBookSvg('betmgm')).toBe(true); + expect(hasBookSvg('MGM')).toBe(true); + }); + + test('a book with no bundled SVG (fanatics) still has NO svg but a real name', () => { + expect(hasBookSvg('fanatics')).toBe(false); + expect(bookInfo('fanatics').name).toBe('Fanatics'); + }); + + test('unknown book β†’ no svg, no crash', () => { + expect(hasBookSvg('zzzbook')).toBe(false); + expect(bookSlug('zzzbook')).toBeNull(); + }); +}); + +describe('BookWordmark source β€” SVG-first, styled-text fallback, no lowercase leak', () => { + const src = read('web/src/components/vyndr/BookWordmark.tsx'); + + test('references the local /books/{slug}.svg path', () => { + expect(src).toContain('/books/'); + expect(src).toMatch(/\$\{slug\}\.svg/); + }); + + test('resolves via the books registry (hasBookSvg + bookInfo + bookSlug)', () => { + expect(src).toContain('hasBookSvg'); + expect(src).toContain('bookInfo'); + expect(src).toContain('bookSlug'); + }); + + test('falls back to the brand NAME (never the raw lowercase key)', () => { + // the fallback renders b.name (properly-cased brand), not the raw `book` prop + expect(src).toContain('b.name'); + expect(src).not.toMatch(/>\s*\{book\}\s* { + test('ledger page imports + uses BookWordmark', () => { + const src = read('web/src/app/ledger/page.tsx'); + expect(src).toMatch(/import\s*\{[^}]*BookWordmark[^}]*\}\s*from\s*'@\/components\/vyndr'/); + expect(src).toContain(' { + const src = read('web/src/app/u/[handle]/PublicProfile.tsx'); + expect(src).toContain('BookWordmark'); + expect(src).toContain(' { expect(resolveTeam('ARI', 'mlb').abbr).toBe('AZ'); expect(resolveTeam('CHW', 'mlb').abbr).toBe('CWS'); }); + test('ESPN-schedule ball-sport abbr aliases (Wave 2B)', () => { + // NBA abbrs the ESPN schedule emits that used to fall to a monogram + expect(resolveTeam('SA', 'nba').abbr).toBe('SAS'); // Spurs + expect(resolveTeam('NY', 'nba').abbr).toBe('NYK'); // Knicks + expect(resolveTeam('WSH', 'nba').abbr).toBe('WAS'); // Wizards + expect(resolveTeam('BRK', 'nba').abbr).toBe('BKN'); // Nets + // WNBA + expect(resolveTeam('CONN', 'wnba').abbr).toBe('CON'); // Sun + expect(resolveTeam('WSH', 'wnba').abbr).toBe('WAS'); // Mystics + }); + test('global alias never shadows a real same-abbr team in another sport', () => { + // WSH is a real MLB abbr (Nationals) β€” must resolve directly, NOT via the + // NBA/WNBA WSHβ†’WAS alias. + expect(resolveTeam('WSH', 'mlb').name).toBe('Washington Nationals'); + // NY is the WNBA Liberty's real abbr β€” direct hit, not the NBA NYβ†’NYK alias. + expect(resolveTeam('NY', 'wnba').name).toBe('New York Liberty'); + // NY is genuinely ambiguous in MLB (NYY/NYM) β†’ stays null, never guessed. + expect(resolveTeam('NY', 'mlb')).toBeNull(); + }); test('unknown team β†’ null (never a fake)', () => { expect(resolveTeam('ZZZ', 'mlb')).toBeNull(); expect(resolveTeam('', 'mlb')).toBeNull(); diff --git a/web/public/books/bet365.svg b/web/public/books/bet365.svg new file mode 100644 index 0000000..73afcbe --- /dev/null +++ b/web/public/books/bet365.svg @@ -0,0 +1 @@ +bet365 diff --git a/web/public/books/betmgm.svg b/web/public/books/betmgm.svg new file mode 100644 index 0000000..211334d --- /dev/null +++ b/web/public/books/betmgm.svg @@ -0,0 +1 @@ +BetMGM diff --git a/web/public/books/betrivers.svg b/web/public/books/betrivers.svg new file mode 100644 index 0000000..0c1d4f9 --- /dev/null +++ b/web/public/books/betrivers.svg @@ -0,0 +1 @@ +BetRivers diff --git a/web/public/books/caesars.svg b/web/public/books/caesars.svg new file mode 100644 index 0000000..9c803b0 --- /dev/null +++ b/web/public/books/caesars.svg @@ -0,0 +1 @@ +Caesars diff --git a/web/public/books/draftkings.svg b/web/public/books/draftkings.svg new file mode 100644 index 0000000..ee3a8b4 --- /dev/null +++ b/web/public/books/draftkings.svg @@ -0,0 +1 @@ +DraftKings diff --git a/web/public/books/fanduel.svg b/web/public/books/fanduel.svg new file mode 100644 index 0000000..18bb053 --- /dev/null +++ b/web/public/books/fanduel.svg @@ -0,0 +1 @@ +FanDuel diff --git a/web/public/books/hardrockbet.svg b/web/public/books/hardrockbet.svg new file mode 100644 index 0000000..19d8f3a --- /dev/null +++ b/web/public/books/hardrockbet.svg @@ -0,0 +1 @@ +Hard Rock diff --git a/web/public/books/pinnacle.svg b/web/public/books/pinnacle.svg new file mode 100644 index 0000000..9bd04b2 --- /dev/null +++ b/web/public/books/pinnacle.svg @@ -0,0 +1 @@ +Pinnacle diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 579bc8d..b41528f 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; -import { Skeleton, EmptyState, ArchetypeBadge } from '@/components/vyndr'; +import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark } from '@/components/vyndr'; import { clvMode, CLV_FLAT_LINE } from '@/lib/clvDisplay'; /** @@ -365,7 +365,7 @@ function LedgerCard({ row, index }: { row: LedgerRow; index: number }) { {row.side} {row.line} {row.stat.replace(/_/g, ' ')}

- {row.book || 'β€”'}{row.locked_odds ? ` Β· ${row.locked_odds}` : ''} Β· {row.game_date} + {row.book ? : 'β€”'}{row.locked_odds ? ` Β· ${row.locked_odds}` : ''} Β· {row.game_date} {/* model_value is MODEL output β€” always labeled, never blended with market numbers. */} {row.model_value != null && Β· MODEL {row.model_value}}

diff --git a/web/src/app/u/[handle]/PublicProfile.tsx b/web/src/app/u/[handle]/PublicProfile.tsx index 03811dd..ed29f34 100644 --- a/web/src/app/u/[handle]/PublicProfile.tsx +++ b/web/src/app/u/[handle]/PublicProfile.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { GradePill } from '@/components/GradeCard'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import BookWordmark from '@/components/vyndr/BookWordmark'; /** * PublicProfile (A1 Session 10) β€” the public ledger record for one handle. @@ -272,7 +273,7 @@ function ProfileCard({ row, index }: { row: ProfileRow; index: number }) {

- {row.book || 'β€”'}{row.locked_odds ? ` Β· ${row.locked_odds}` : ''} Β· {row.game_date} + {row.book ? : 'β€”'}{row.locked_odds ? ` Β· ${row.locked_odds}` : ''} Β· {row.game_date} {row.model_value != null && Β· MODEL {row.model_value}}

diff --git a/web/src/components/vyndr/BookWordmark.tsx b/web/src/components/vyndr/BookWordmark.tsx index 04628b2..7c66c9f 100644 --- a/web/src/components/vyndr/BookWordmark.tsx +++ b/web/src/components/vyndr/BookWordmark.tsx @@ -1,10 +1,16 @@ -import { bookInfo } from '@/lib/books'; +import { bookInfo, bookSlug, hasBookSvg } from '@/lib/books'; /** - * BookWordmark (DS0) β€” a sportsbook renders as its brand: the real name in - * the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare - * lowercase "draftkings" string (DESIGN-SPEC Part 2). For inline contexts - * where the BookChip tile is too heavy. `best` gives it the signal ring. + * BookWordmark (DS0 Β· Wave 2B) β€” a sportsbook renders as its brand. When a + * bundled local wordmark SVG exists (`web/public/books/{slug}.svg`, for the ~8 + * major books) it renders that; otherwise it falls back to the real book NAME + * in the book's brand color, properly cased (DraftKings, FanDuel), NEVER a bare + * lowercase "draftkings" string (DESIGN-SPEC Part 2). + * + * The bundled SVGs are self-authored styled text wordmarks β€” not copied + * trademarked logo glyphs β€” so the founder can drop official press-kit art into + * the same paths later with zero code change. The files are local + always + * present, so no broken-image state is possible. `best` gives the signal ring. */ export default function BookWordmark({ book, @@ -16,6 +22,8 @@ export default function BookWordmark({ size?: number; }) { const b = bookInfo(book); + const slug = bookSlug(book); + const svg = hasBookSvg(book) && slug ? `/books/${slug}.svg` : null; return ( {best && } - {b.name} + {svg ? ( + // eslint-disable-next-line @next/next/no-img-element + {b.name} + ) : ( + b.name + )} ); } diff --git a/web/src/components/vyndr/GameCard.tsx b/web/src/components/vyndr/GameCard.tsx index ef68c2a..53d8611 100644 --- a/web/src/components/vyndr/GameCard.tsx +++ b/web/src/components/vyndr/GameCard.tsx @@ -9,7 +9,7 @@ import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@ import TeamLogo from '@/components/vyndr/TeamLogo'; import { accentColor } from '@/lib/teamMeta'; import { playerHref } from '@/lib/playerHref'; -import { isPreferredBook } from '@/lib/books'; +import { isPreferredBook, bookInfo } from '@/lib/books'; import { pendingSummary, topReadForCard } from '@/lib/slateAdapter'; import { nextRunLabelET } from '@/lib/pipelineSchedule'; import { useParlay, legKey } from '@/contexts/ParlayContext'; @@ -301,7 +301,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
O/U
{g.lines.map((ln, i) => ( -
{ln.book}
+
{bookInfo(ln.book).name}
diff --git a/web/src/lib/books.js b/web/src/lib/books.js index 48a42c7..4e5f2d1 100644 --- a/web/src/lib/books.js +++ b/web/src/lib/books.js @@ -1,29 +1,69 @@ /* Sportsbook brand map (Session 42) β€” ported from the design's BookChip.dc.html - BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */ + BOOKS table. CommonJS so it's testable + importable from the .tsx chip. + + Wave 2B (data train) β€” the map now covers EVERY key the odds feed emits + (`oddsNormalizer.ALLOWED_BOOKS`): draftkings, fanduel, betmgm, caesars, + fanatics, bet365, hardrockbet, pointsbet, betrivers, pinnacle. No live book + falls to the neutral-gray default. Entries carry a `slug` = the canonical + lowercase book key; `bookSlug()` resolves any input (id/code/name) to it, and + BUNDLED_BOOK_SVGS names the 8 books that have a local wordmark SVG under + `web/public/books/{slug}.svg` (self-authored styled wordmarks, swappable for + official press-kit art without a code change). */ const BOOKS = { - DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, - DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, - FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, - FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, - MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, - BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, - CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, - CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, + DK: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, + DRAFTKINGS: { name: 'DraftKings', mono: 'DK', slug: 'draftkings', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' }, + FD: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, + FANDUEL: { name: 'FanDuel', mono: 'FD', slug: 'fanduel', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' }, + MGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, + BETMGM: { name: 'BetMGM', mono: 'MGM', slug: 'betmgm', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' }, + CZR: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, + CAESARS: { name: 'Caesars', mono: 'CZR', slug: 'caesars', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' }, ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, 'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' }, - BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' }, + // BetRivers is a blue book (its logo is blue "BetRivers"), not purple. + BR: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, + BETRIVERS: { name: 'BetRivers', mono: 'BR', slug: 'betrivers', bg: '#08121F', fg: '#3E8FD6', bd: '#3E8FD655' }, PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' }, - FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, - B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' }, - HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + FAN: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, + FANATICS: { name: 'Fanatics', mono: 'FAN', slug: 'fanatics', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' }, + B365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' }, + BET365: { name: 'bet365', mono: '365', slug: 'bet365', bg: '#0A1A12', fg: '#3EA76B', bd: '#3EA76B66' }, + HR: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + HARDROCKBET: { name: 'Hard Rock', mono: 'HR', slug: 'hardrockbet', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' }, + POINTSBET: { name: 'PointsBet', mono: 'PTS', slug: 'pointsbet', bg: '#1F0808', fg: '#E4344A', bd: '#E4344A55' }, + PINNACLE: { name: 'Pinnacle', mono: 'PIN', slug: 'pinnacle', bg: '#180C0E', fg: '#C8434F', bd: '#C8434F55' }, UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' }, }; +// The 8 books with a bundled local wordmark SVG (web/public/books/{slug}.svg). +// Self-authored styled text wordmarks β€” NOT copied trademarked logo glyphs. +const BUNDLED_BOOK_SVGS = new Set([ + 'draftkings', 'fanduel', 'betmgm', 'caesars', 'bet365', 'pinnacle', 'hardrockbet', 'betrivers', +]); + +const DEFAULT_FG = '#B8BCC8'; + function bookInfo(book) { const key = String(book == null ? '' : book).toUpperCase(); - return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' }; + return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: DEFAULT_FG, bd: '#23232F' }; +} + +/** Canonical lowercase book slug for any input (id/code/name), or null when the + * book isn't in the registry. Used to look up the bundled wordmark SVG. */ +function bookSlug(book) { + const info = bookInfo(book); + if (info.slug) return info.slug; + // Unknown-but-nameable: derive a slug from the resolved name (still not the + // raw lowercase feed key). Only bundled slugs matter for the SVG lookup. + return null; +} + +/** Does `book` have a bundled local wordmark SVG? */ +function hasBookSvg(book) { + const slug = bookSlug(book); + return slug != null && BUNDLED_BOOK_SVGS.has(slug); } /** Canonical comparison key for a book (Session 49) β€” resolves "DK"/"draftkings" @@ -39,4 +79,4 @@ function isPreferredBook(book, preferred) { return preferred.some((p) => bookKey(p) === k); } -module.exports = { BOOKS, bookInfo, bookKey, isPreferredBook }; +module.exports = { BOOKS, BUNDLED_BOOK_SVGS, bookInfo, bookSlug, hasBookSvg, bookKey, isPreferredBook }; diff --git a/web/src/lib/teamMeta.js b/web/src/lib/teamMeta.js index bc230ed..27cd0bb 100644 --- a/web/src/lib/teamMeta.js +++ b/web/src/lib/teamMeta.js @@ -195,7 +195,16 @@ function resolveTeam(key, sport) { const up = String(key).toUpperCase().trim(); if (table[up]) return { abbr: up, sport: sp, ...table[up] }; // statsapi/ESPN alias fallbacks for the two MLB mismatches - const ALIAS = { ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX' }; + // Global alias table. resolveTeam checks the real abbr FIRST, so an entry + // only fires for a sport whose table lacks that key β€” e.g. WSH resolves + // directly to the MLB Nationals, but NBA/WNBA (no WSH key) fall through to + // WAS (Wizards/Mystics). ESPN-schedule abbrs the ball sports actually emit + // (SA/NY/WSH/BRK for NBA, CONN for WNBA) are covered here. + const ALIAS = { + ARI: 'AZ', CHW: 'CWS', OAK: 'ATH', SFG: 'SF', TBR: 'TB', WSN: 'WSH', KCR: 'KC', SDP: 'SD', + GS: 'GSW', NO: 'NOP', NYK: 'NYK', UTAH: 'UTA', PHO: 'PHX', + SA: 'SAS', NY: 'NYK', WSH: 'WAS', BRK: 'BKN', CONN: 'CON', + }; if (ALIAS[up] && table[ALIAS[up]]) return { abbr: ALIAS[up], sport: sp, ...table[ALIAS[up]] }; if (sp === 'soccer' && SOCCER_ALIAS[normKey(key)]) { const a = SOCCER_ALIAS[normKey(key)]; From 47ada9013cfda6ac460e298389289b956a94b393 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 13:18:59 -0400 Subject: [PATCH 06/15] =?UTF-8?q?Wave=202A:=20real=20player=20headshots=20?= =?UTF-8?q?=E2=80=94=20sport-agnostic=20id=20threaded=20from=20ingestion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads a REAL athlete id from the snapshot's per-player stats resolve (zero new I/O) β†’ enriched grade β†’ grades:{sport} β†’ slate strip β†’ PlayerAvatar. Real photo where an id resolves; team-colored monogram (never a gray silhouette, never a broken image) where it can't. Ids are never fabricated. Ingestion (Addition 1): - espnStatsAdapter.getSeasonAverages now RETURNS the resolved ESPN athlete id (was discarded) as espnId; non-numeric uid degrades to null. - playerIntelService surfaces MLBAM playerId (MLB) / ESPN espnId (NBA/WNBA). - snapshotService captures both per player and stores them on the enriched grade beside archetype/team (null when unresolved β†’ monogram path). Thread β†’ component: - slateAdapter.buildPlayerStripsFromProps carries playerId/espnId onto each strip; StatStrip β†’ PlayerAvatar (accepts both ids; getHeadshotUrl routes by sport: MLBβ†’mlbstatic, NBA/WNBAβ†’a.espncdn). - Silhouette surfaces rewired to PlayerAvatar (branded monogram on null): scan search dropdown (guarded MLBAM p.id) + tonight chips, SearchModal, HotListPanel, GradeResultCard header. Scan grade card feeds the picked MLBAM id through gradeAdapter. - playerHeadshot pure URL logic extracted to CommonJS playerHeadshotUrl.js (unit-testable; the .ts re-exports it). nfl/nhl added to ESPN_SPORT_PATH. Tests: tests/unit/headshotThread.test.js (per-league URL + id thread + monogram null path) + extended snapshotService/espnStatsAdapter suites. Full suite 241 suites / 2915 green; next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/services/adapters/espnStatsAdapter.js | 10 ++ src/services/playerIntelService.js | 4 +- src/services/snapshotService.js | 29 ++++- tests/unit/espnStatsAdapter.test.js | 18 ++- tests/unit/headshotThread.test.js | 110 +++++++++++++++++++ tests/unit/snapshotService.test.js | 28 +++++ web/src/app/scan/page.tsx | 66 +++++------ web/src/components/HotListPanel.tsx | 21 ++-- web/src/components/vyndr/GameCard.tsx | 5 + web/src/components/vyndr/GradeResultCard.tsx | 13 ++- web/src/components/vyndr/PlayerAvatar.tsx | 11 +- web/src/components/vyndr/SearchModal.tsx | 26 ++++- web/src/components/vyndr/StatStrip.tsx | 8 +- web/src/lib/gradeAdapter.js | 3 + web/src/lib/playerHeadshot.ts | 82 +++++--------- web/src/lib/playerHeadshotUrl.js | 61 ++++++++++ web/src/lib/slateAdapter.js | 7 ++ 17 files changed, 381 insertions(+), 121 deletions(-) create mode 100644 tests/unit/headshotThread.test.js create mode 100644 web/src/lib/playerHeadshotUrl.js diff --git a/src/services/adapters/espnStatsAdapter.js b/src/services/adapters/espnStatsAdapter.js index 2c90265..5b99a13 100644 --- a/src/services/adapters/espnStatsAdapter.js +++ b/src/services/adapters/espnStatsAdapter.js @@ -89,6 +89,13 @@ async function getSeasonAverages(name, sport, opts = {}) { const id = athlete && (athlete.id || athlete.uid || (athlete.athlete && athlete.athlete.id)); if (!id) return { found: false }; + // Wave 2A β€” the REAL ESPN athlete id for the headshot CDN + // (a.espncdn.com/i/headshots/{league}/players/full/{espnId}.png). Prefer the + // pure numeric id; a `uid` string ("s:40~l:46~a:…") is NOT a valid headshot + // id, so it degrades to null β†’ monogram. Never fabricate. + const numericId = (athlete && (athlete.id ?? (athlete.athlete && athlete.athlete.id))) ?? null; + const espnId = numericId != null && /^\d+$/.test(String(numericId)) ? String(numericId) : null; + // 2. Fetch that athlete's stats overview. const stats = await fetchJson(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/stats`, opts.http); const classifierInput = parseAthleteStats(stats); @@ -99,6 +106,9 @@ async function getSeasonAverages(name, sport, opts = {}) { team: (athlete.team && (athlete.team.abbreviation || athlete.team.displayName)) || '', position: (athlete.position && athlete.position.abbreviation) || '', classifierInput, + // Wave 2A β€” surfaced so resolvePlayerStats can thread it to the grade β†’ + // slate strip β†’ headshot. Absent β†’ monogram (doctrine). + espnId, }; try { await cacheSet(cacheKey, result, TTL); } catch { /* ignore */ } return result; diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js index 5b71a68..4cc57b5 100644 --- a/src/services/playerIntelService.js +++ b/src/services/playerIntelService.js @@ -165,7 +165,9 @@ async function resolvePlayerStats(name, sport, opts = {}) { const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes); const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {}; if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) }); - return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [] }; + // Wave 2A β€” the REAL ESPN athlete id (headshot CDN) surfaces from the + // adapter. Absent β†’ no id β†’ monogram. Never guessed. + return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null }; } return { found: false }; } diff --git a/src/services/snapshotService.js b/src/services/snapshotService.js index b65b1c9..8446270 100644 --- a/src/services/snapshotService.js +++ b/src/services/snapshotService.js @@ -298,6 +298,13 @@ async function runSnapshot(sport, opts = {}) { // (statsapi/ESPN), never guessed. Feeds the ledger team/opponent columns // and the slate join guard (a prop only attaches to its own game). const teamByPlayer = {}; + // Wave 2A β€” the REAL athlete id from the SAME stats resolve, keyed by player. + // MLB β†’ MLBAM id (mlbstatic headshot CDN); NBA/WNBA β†’ ESPN athlete id + // (a.espncdn headshot CDN). Stored on the enriched grade so it flows free to + // grades:{sport} β†’ slate strips β†’ PlayerAvatar. Zero new I/O. Absent β†’ the + // component falls to a team-colored monogram (never a fabricated face). + const playerIdByPlayer = {}; + const espnIdByPlayer = {}; // Wave 1 (trust bug) β€” the prop's game participants become the resolve's // teamHint: it disambiguates namesake collisions (two "James Wood") and, when // the resolved player's real team isn't in the prop's game, the resolver drops @@ -326,6 +333,9 @@ async function runSnapshot(sport, opts = {}) { const c = deps.classify(sp, stats.classifierInput || {}); archByPlayer[player] = c.primary ? c.primary.name : null; if (stats.team) teamByPlayer[player] = stats.team; + // Wave 2A β€” capture the resolved athlete id (headshot thread). + if (stats.playerId != null) playerIdByPlayer[player] = stats.playerId; + if (stats.espnId != null) espnIdByPlayer[player] = stats.espnId; if (Array.isArray(stats.rawLog) && stats.rawLog.length > 0) { logEntries.push({ name: normalizeName(player).display || player, @@ -341,12 +351,19 @@ async function runSnapshot(sport, opts = {}) { }); await mergeRosterLogs(sp, logEntries, deps); - const enriched = graded.map((g) => ({ - ...g, - gradedAt: gradedAtFor(g, oddsByKey, ts), - archetype: archByPlayer[g.player || g.player_name] || null, - team: teamByPlayer[g.player || g.player_name] || g.team || null, - })); + const enriched = graded.map((g) => { + const pn = g.player || g.player_name; + return { + ...g, + gradedAt: gradedAtFor(g, oddsByKey, ts), + archetype: archByPlayer[pn] || null, + team: teamByPlayer[pn] || g.team || null, + // Wave 2A β€” real headshot id (MLBAM for MLB, ESPN for NBA/WNBA), threaded + // from the stats resolve above. Absent β†’ PlayerAvatar renders a monogram. + playerId: playerIdByPlayer[pn] ?? g.playerId ?? null, + espnId: espnIdByPlayer[pn] ?? g.espnId ?? null, + }; + }); // Line deltas vs the previous snapshot's locked lines. const prev = await deps.cacheGet(`snapshot:${sp}:latest`); diff --git a/tests/unit/espnStatsAdapter.test.js b/tests/unit/espnStatsAdapter.test.js index 7f234ce..a8dfea0 100644 --- a/tests/unit/espnStatsAdapter.test.js +++ b/tests/unit/espnStatsAdapter.test.js @@ -40,6 +40,20 @@ describe('getSeasonAverages (injected http)', () => { expect(r.found).toBe(true); expect(r.team).toBe('DAL'); expect(r.classifierInput.ppg).toBe(33); + // Wave 2A β€” the REAL ESPN athlete id is surfaced (headshot CDN), not discarded. + expect(r.espnId).toBe('123'); + }); + + it('Wave 2A β€” a non-numeric uid degrades espnId to null (never fabricated)', async () => { + const http = { + get: async (url) => { + if (url.includes('/search')) return { data: { items: [{ uid: 's:40~l:46~a:999', displayName: 'X', team: {}, position: {} }] } }; + return { data: { statistics: { splits: { categories: [{ stats: [{ name: 'avgPoints', value: 10 }] }] } } } }; + }, + }; + const r = await espn.getSeasonAverages('X', 'nba', { http }); + expect(r.found).toBe(true); + expect(r.espnId).toBeNull(); }); it('degrades to found:false when ESPN errors', async () => { @@ -56,11 +70,13 @@ describe('resolvePlayerStats wires the ESPN fallback for NBA', () => { it('falls back to ESPN when nbaStatsClient is offline β†’ classifies', async () => { const r = await svc.resolvePlayerStats('Luka Doncic', 'nba', { nbaClient: { getSeasonAvg: async () => { throw new Error('python offline'); } }, - espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 } }) }, + espnStats: { getSeasonAverages: async () => ({ found: true, team: 'DAL', position: 'G', classifierInput: { ppg: 33, apg: 9, rpg: 8 }, espnId: '3945274' }) }, }); expect(r.found).toBe(true); expect(r.team).toBe('DAL'); expect(r.classifierInput.ppg).toBe(33); + // Wave 2A β€” espnId surfaces through resolvePlayerStats β†’ the snapshot grade. + expect(r.espnId).toBe('3945274'); }); it('found:false when both sources are empty', async () => { diff --git a/tests/unit/headshotThread.test.js b/tests/unit/headshotThread.test.js new file mode 100644 index 0000000..e76fd28 --- /dev/null +++ b/tests/unit/headshotThread.test.js @@ -0,0 +1,110 @@ +// Wave 2A (WIRING & DATA TRAIN, Step 2) β€” the headshot id thread. +// +// Doctrine: a REAL athlete photo where an id resolves; a team-colored monogram +// (NEVER a gray silhouette, NEVER a broken image) where it can't. The id is +// NEVER fabricated β€” it rides free on the snapshot's per-player stats resolve. +// +// This suite locks the four links of the chain: +// (a) getHeadshotUrl builds the right per-league CDN URL from a known id +// (b) an MLB grade with a resolved MLBAM id β†’ playerId on the strip +// (c) an NBA/WNBA grade with a resolved ESPN id β†’ espnId on the strip +// (d) a grade with NO id β†’ strip carries no id β†’ PlayerAvatar falls to a +// monogram (the null path). Absent beats fabricated. + +// The PURE URL core is CommonJS (the .ts re-exports it verbatim); jest can't +// transform the .ts, so we require the same single source of truth here. +const { getHeadshotUrl } = require('../../web/src/lib/playerHeadshotUrl'); +const adapter = require('../../web/src/lib/slateAdapter'); + +describe('(a) getHeadshotUrl β€” per-league CDN URL from a real id', () => { + it('MLB β†’ img.mlbstatic.com via the MLBAM people id', () => { + // Aaron Judge = MLBAM 592450. + const url = getHeadshotUrl({ sport: 'mlb', playerId: 592450 }); + expect(url).toBe( + 'https://img.mlbstatic.com/mlb-photos/image/upload/d_people:generic:headshot:67:current.png/w_213,q_auto:best/v1/people/592450/headshot/67/current', + ); + }); + + it('NBA β†’ a.espncdn headshot from the ESPN athlete id (espnId, no playerId)', () => { + const url = getHeadshotUrl({ sport: 'nba', espnId: 3945274 }); + expect(url).toBe( + 'https://a.espncdn.com/combiner/i?img=/i/headshots/nba/players/full/3945274.png&w=130&h=95', + ); + }); + + it('WNBA β†’ a.espncdn headshot from the ESPN athlete id', () => { + const url = getHeadshotUrl({ sport: 'wnba', espnId: 4066533 }); + expect(url).toBe( + 'https://a.espncdn.com/combiner/i?img=/i/headshots/wnba/players/full/4066533.png&w=130&h=95', + ); + }); + + it('dormant NFL/NHL leagues now resolve an ESPN headshot path (cheap correctness)', () => { + expect(getHeadshotUrl({ sport: 'nfl', espnId: 3139477 })).toContain('/headshots/nfl/players/full/3139477.png'); + expect(getHeadshotUrl({ sport: 'nhl', espnId: 3024816 })).toContain('/headshots/nhl/players/full/3024816.png'); + }); + + it('no id at all β†’ the neutral silhouette sentinel (component swaps to monogram)', () => { + expect(getHeadshotUrl({ sport: 'mlb' })).toBe('/images/player-silhouette.svg'); + expect(getHeadshotUrl({ sport: 'soccer', playerId: 123 })).toBe('/images/player-silhouette.svg'); + }); +}); + +describe('(b) MLB grade β†’ playerId threads onto the strip', () => { + it('carries the MLBAM playerId from the enriched grade to the strip prop group', () => { + const props = [{ player: 'Aaron Judge', stat_type: 'hits', line: 1.5, home_team: 'NYY', away_team: 'BOS' }]; + const gradeIndex = adapter.indexGrades([ + { + player: 'Aaron Judge', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', + playerId: 592450, team: 'NYY', + gradedAt: { line: 1.5, timestamp: '2026-07-10T02:00:00Z' }, + }, + ]); + const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}); + expect(strips).toHaveLength(1); + expect(strips[0].playerId).toBe(592450); + expect(strips[0].espnId).toBeUndefined(); + // And the id builds the real MLB headshot. + expect(getHeadshotUrl({ sport: 'mlb', playerId: strips[0].playerId })).toContain('/people/592450/headshot'); + }); +}); + +describe('(c) NBA/WNBA grade β†’ espnId threads onto the strip', () => { + it('carries the ESPN espnId from the enriched grade to the strip prop group', () => { + const props = [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, home_team: 'IND', away_team: 'CHI' }]; + const gradeIndex = adapter.indexGrades([ + { + player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'B+', + espnId: 4433403, team: 'IND', + gradedAt: { line: 22.5, timestamp: '2026-07-10T02:00:00Z' }, + }, + ]); + const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}); + expect(strips).toHaveLength(1); + expect(strips[0].espnId).toBe(4433403); + expect(strips[0].playerId).toBeUndefined(); + expect(getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId })).toContain('/players/full/4433403.png'); + }); +}); + +describe('(d) no resolved id β†’ monogram path (never a fabricated face)', () => { + it('a grade with no id β†’ strip has neither playerId nor espnId', () => { + const props = [{ player: 'Unknown Prospect', stat_type: 'hits', line: 0.5, home_team: 'NYY', away_team: 'BOS' }]; + const gradeIndex = adapter.indexGrades([ + { + player: 'Unknown Prospect', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'C', + team: 'NYY', + gradedAt: { line: 0.5, timestamp: '2026-07-10T02:00:00Z' }, + }, + ]); + const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {}); + expect(strips[0].playerId).toBeUndefined(); + expect(strips[0].espnId).toBeUndefined(); + // PlayerAvatar renders `url = (playerId!=null || espnId!=null) ? … : null`, + // so an absent id yields a null url β†’ the branded monogram. Prove the + // resolver returns the silhouette sentinel (which the component swaps out) + // rather than a fabricated CDN URL when no id is present. + expect(getHeadshotUrl({ sport: 'mlb', playerId: strips[0].playerId, espnId: strips[0].espnId })) + .toBe('/images/player-silhouette.svg'); + }); +}); diff --git a/tests/unit/snapshotService.test.js b/tests/unit/snapshotService.test.js index 2e0150f..9b3c1aa 100644 --- a/tests/unit/snapshotService.test.js +++ b/tests/unit/snapshotService.test.js @@ -115,6 +115,34 @@ describe('runSnapshot (fully injected)', () => { expect(cache.store['grades:mlb'].grades).toHaveLength(2); }); + it('Wave 2A β€” threads the resolved athlete id (playerId/espnId) onto the enriched grade', async () => { + const cache = memCache(); + const d = deps(cache); + // Judge resolves an MLBAM id; Betts resolves an ESPN id (cross-sport shape). + d.resolveStats = async (player) => (player === 'Aaron Judge' + ? { found: true, classifierInput: { hr: 34, avg: 0.28, ops: 0.95, k_rate: 28 }, playerId: 592450 } + : { found: true, classifierInput: {}, espnId: 4433403 }); + await svc.runSnapshot('mlb', d); + const snap = cache.store['snapshot:mlb:latest']; + const judge = snap.grades.find((g) => g.player === 'Aaron Judge'); + const betts = snap.grades.find((g) => g.player === 'Mookie Betts'); + expect(judge.playerId).toBe(592450); + expect(betts.espnId).toBe(4433403); + // grades:{sport} inherits the same ids (GameCard/Explore read from it). + const g = cache.store['grades:mlb'].grades.find((x) => x.player === 'Aaron Judge'); + expect(g.playerId).toBe(592450); + }); + + it('Wave 2A β€” no resolved id β†’ enriched grade carries null ids (monogram path)', async () => { + const cache = memCache(); + const d = deps(cache); + d.resolveStats = async () => ({ found: false }); // nothing resolves + await svc.runSnapshot('mlb', d); + const snap = cache.store['snapshot:mlb:latest']; + expect(snap.grades[0].playerId).toBeNull(); + expect(snap.grades[0].espnId).toBeNull(); + }); + it('rotates latest β†’ previous and computes deltas on the second run', async () => { const cache = memCache(); let line = 1.5; diff --git a/web/src/app/scan/page.tsx b/web/src/app/scan/page.tsx index 56e4244..61964c1 100644 --- a/web/src/app/scan/page.tsx +++ b/web/src/app/scan/page.tsx @@ -16,7 +16,8 @@ import { trackScanLimitHit, trackUpgradeClicked, } from '@/lib/analytics'; -import { getHeadshotUrl, PLAYER_SILHOUETTE, type HeadshotSport } from '@/lib/playerHeadshot'; +import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; +import { type HeadshotSport } from '@/lib/playerHeadshot'; import { buildBookLink, SUPPORTED_BOOKS, BOOK_LINK_REL } from '@/lib/bookLinks'; type Sport = 'NBA' | 'MLB' | 'WNBA'; @@ -119,6 +120,9 @@ export default function ScanPage() { const [playerQuery, setPlayerQuery] = useState(''); const [playerSuggestions, setPlayerSuggestions] = useState([]); const [selectedPlayer, setSelectedPlayer] = useState(''); + // Wave 2A β€” the MLBAM id of the player picked from search (MLB only; numeric). + // Feeds the grade card's real headshot. null β†’ team-colored monogram. + const [selectedPlayerId, setSelectedPlayerId] = useState(null); const [stat, setStat] = useState('points'); const [line, setLine] = useState(''); const [direction, setDirection] = useState<'over' | 'under'>('over'); @@ -306,6 +310,7 @@ export default function ScanPage() { setError(''); setPlayerQuery(''); setSelectedPlayer(''); + setSelectedPlayerId(null); setLine(''); }; @@ -442,7 +447,6 @@ export default function ScanPage() { }} > {tonightsPlayers.map((p) => { - const headshot = getHeadshotUrl({ sport: sport.toLowerCase() as HeadshotSport }); const selected = selectedPlayer === p.name; return ( + ))} +
+ + {/* Search β€” independent of the slate; resolves via /api/players/search */} + setQ(e.target.value)} + placeholder="Search a player…" + aria-label="Search a player" + className="mono" + style={{ + width: "100%", padding: "10px 12px", marginBottom: 6, borderRadius: 9, fontSize: 13, + background: "var(--bg-2)", border: "1px solid var(--border)", color: "var(--text-0)", + }} + /> + {suggest.length > 0 && ( +
+ {suggest.join(" Β· ")} +
+ )} + +
+ {/* LEFT β€” tonight's A/B reads (the leg source) */} +
+
+ + TONIGHT'S GRADED READS + TAP TO ADD +
+ + {loading ? ( +
Loading tonight's reads…
+ ) : filtered.length === 0 ? ( +
+ {searchedButEmpty + ? `No graded ${sport} reads for β€œ${q.trim()}” yet β€” grades post on the next snapshot.` + : `No graded ${sport} reads on the board right now. Check back after the next snapshot.`} +
+ ) : ( +
+ {filtered.map((p) => { + const active = hasLeg(p.key); + return ( + + ); + })} +
+ )} +
+ + {/* RIGHT β€” the PARLAY SLIP (reads combined/correlation/payout from context) */} +
+
+ PARLAY SLIP + {legs.length} LEG{legs.length === 1 ? "" : "S"} +
+ + {/* legs */} +
+ {legs.length === 0 ? ( +
+ No legs yet β€” tap a graded read to start building. We grade the combined correlation and flag legs that secretly fight each other. +
+ ) : ( + legs.map((l) => ( +
+ {l.archetype && } + {l.player} + {statLabel(l.stat)} {l.direction === "under" ? "U" : "O"}{l.line} + + +
+ )) + )} +
+ + {/* leg-cap notice (tier-aware) */} + {legs.length >= maxLegs && ( +
+ {fullLab ? `Max ${maxLegs} legs on your plan.` : "Free tier caps at 2 legs β€” upgrade to Desk for 6."} +
+ )} + + {/* CAUTION Β· CORRELATION FLAG β€” surfaces parlayService's warning */} + {correlation?.warning && ( +
+
⚠ CAUTION · CORRELATION FLAG
+
{correlation.warning}
+
+ )} + + {/* combined / grade / stake */} +
+
+
CORRELATION
+
0.3 ? "var(--amber)" : "var(--g-a)" }}> + {legs.length >= 2 && correlation ? correlation.avg.toFixed(2) : "β€”"} +
+
+
+
GRADE
+ {legs.length >= 2 && combined ? : {grading ? "…" : "β€”"}} +
+
+
EST Β· $10
+ {legs.length < 2 ? ( + β€” + ) : fullLab ? ( + {payout ? `$${payout.amount.toFixed(2)}` : grading ? "…" : "β€”"} + ) : ( + + $38.50 + + + )} +
+
+ + {legs.length > 0 && ( + + )} +
+
+ + ); +} diff --git a/web/src/components/ParlayTray.tsx b/web/src/components/ParlayTray.tsx deleted file mode 100644 index 1fa6390..0000000 --- a/web/src/components/ParlayTray.tsx +++ /dev/null @@ -1,231 +0,0 @@ -'use client'; - -import { useEffect, useMemo, useState } from 'react'; -import { useParlay, type ParlayLeg } from '@/contexts/ParlayContext'; -import { GradePill } from './GradeCard'; -import { trackParlayBuilt } from '@/lib/analytics'; - -interface ParlayGradeResponse { - parlay_grade: string; - parlay_confidence: number; - correlation_flags: { type: string; legs: number[]; detail: string; impact: string }[]; - decimal_odds?: number; -} - -export default function ParlayTray() { - const { legs, isOpen, close, removeLeg, clear } = useParlay(); - const [grading, setGrading] = useState(false); - const [parlayResult, setParlayResult] = useState(null); - - // Reset the parlay grade whenever the leg set changes - useEffect(() => { - setParlayResult(null); - }, [legs]); - - const sports = useMemo(() => Array.from(new Set(legs.map((l) => l.sport))), [legs]); - - const gradeParlay = async () => { - if (legs.length < 2) return; - setGrading(true); - try { - const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null; - const res = await fetch('/api/parlay/grade', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ - legs: legs.map((l) => ({ - sport: l.sport, - player: l.player, - stat_type: l.stat, - line: l.line, - direction: l.direction, - })), - }), - }); - const data = (await res.json()) as ParlayGradeResponse; - if (res.ok) { - setParlayResult(data); - trackParlayBuilt({ legs: legs.length, sports, grade: data.parlay_grade }); - } - } finally { - setGrading(false); - } - }; - - if (!isOpen) return null; - - return ( -
- - - - {legs.length === 0 ? ( - - ) : ( -
    - {legs.map((l) => ( - removeLeg(l.id)} /> - ))} -
- )} - - {parlayResult && ( -
-

- PARLAY GRADE -

-
- -
- {parlayResult.correlation_flags.length > 0 && ( -
-

- CORRELATION WARNINGS -

- {parlayResult.correlation_flags.map((f, i) => ( -

- {f.detail} -

- ))} -
- )} -
- )} - - {legs.length > 0 && ( -
- - -
- )} - -
- ); -} - -function EmptyTrayCopy() { - return ( -
-

- NO LEGS YET -

-

- Read a prop, hit Add to Parlay, and we'll build the slip here. - We grade overall correlation and surface the legs that secretly fight each other. -

-
- ); -} - -function LegRow({ leg, onRemove }: { leg: ParlayLeg; onRemove: () => void }) { - return ( -
  • -
    -
    {leg.player}
    -
    - {leg.sport} Β· {leg.direction} {leg.line} {leg.stat.replace(/_/g, ' ')} -
    -
    -
    - - -
    -
  • - ); -} diff --git a/web/src/components/vyndr/GradeResultCard.tsx b/web/src/components/vyndr/GradeResultCard.tsx index 143e38f..8d789e7 100644 --- a/web/src/components/vyndr/GradeResultCard.tsx +++ b/web/src/components/vyndr/GradeResultCard.tsx @@ -6,6 +6,7 @@ import SectionHead from '@/components/vyndr/SectionHead'; import VBtn from '@/components/vyndr/VBtn'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import GradeBadge from '@/components/vyndr/GradeBadge'; +import GradeShift from '@/components/vyndr/GradeShift'; import PlayerAvatar from '@/components/vyndr/PlayerAvatar'; import { type HeadshotSport } from '@/lib/playerHeadshot'; import { gradeColor, gradeHex } from '@/lib/vyndrTokens'; @@ -39,6 +40,12 @@ export interface GradeResultData { propDNA?: { reliable: string[]; volatile: string[] }; statContext?: { season?: string; last10?: string; vsOpp?: string }; vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string }; + // Wave 4B β€” LIVE GRADE-SHIFT timeline (all optional; GradeShift self-hides + // below 3 real captured points). Fed by the snapshot pipeline's already- + // emitted line history + public revision; the scan path leaves them absent. + history?: Array<{ t: string; line: number }> | null; + revisedFrom?: string | null; + gradedLine?: number | null; } interface GradeResultCardProps { @@ -198,6 +205,12 @@ export default function GradeResultCard({ ))} + {/* 4b. LIVE GRADE-SHIFT (Wave 4B) β€” the line/grade movement timeline over + the snapshot pipeline's already-emitted history. Self-hides below 3 + real captured points, so the scan path (no captured history) shows + nothing rather than a fabricated timeline. */} + + {/* 5. SIGNAL BREAKDOWN */} {d.signals.length > 0 && (
    diff --git a/web/src/components/vyndr/GradeShift.tsx b/web/src/components/vyndr/GradeShift.tsx new file mode 100644 index 0000000..6abe937 --- /dev/null +++ b/web/src/components/vyndr/GradeShift.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { buildGradeTimeline } from "@/lib/gradeShift"; + +/** + * GradeShift (Wave 4B) β€” the LIVE GRADE-SHIFT / GRADE HISTORY Β· LAST 24 view. + * A timeline VIEW over ALREADY-EMITTED data: the intraday line-history points + * ({t, line}) + any public revision (revised_from_grade). No new backend. + * + * Doctrine: + * - Self-hides below 3 real history points (buildGradeTimeline.show). + * - Color law (ROW-GRAMMAR): green = toward the graded side, amber = against, + * dim = flat. NEVER red β€” nothing here is settled. + * - A revision shows the ORIGINAL grade struck-through (never silently dropped). + * - Data is mono; nothing glitches. + */ +export interface GradeShiftHistoryPoint { + t: string; + line: number; +} + +interface GradeShiftProps { + history?: Array | null; + side?: string; + grade?: string | null; + revisedFrom?: string | null; + gradedLine?: number | null; +} + +export default function GradeShift({ history, side, grade, revisedFrom, gradedLine }: GradeShiftProps) { + const tl = buildGradeTimeline({ history, side, grade, revisedFrom, gradedLine }); + if (!tl.show) return null; // honest self-hide β€” no fabricated timeline + + const lines = tl.points.map((p) => p.line); + const min = Math.min(...lines); + const max = Math.max(...lines); + const rng = max - min || 1; + const netSign = tl.net.delta > 0 ? "+" : ""; + + return ( +
    + {/* header + legend */} +
    + + GRADE HISTORY Β· LAST 24 + + + {tl.net.dir === "toward" ? "TOWARD" : tl.net.dir === "against" ? "AGAINST" : "FLAT"} {netSign}{tl.net.delta} + +
    + + {/* revision β€” original grade struck-through, never silently */} + {tl.revision && ( +
    + REVISED + {tl.revision.from} + β†’ + {tl.revision.to} +
    + )} + + {/* bars β€” one per capture, height by relative line, colored by segment dir */} +
    + {tl.points.map((p, i) => { + const h = 30 + ((p.line - min) / rng) * 70; // 30%..100% + const isGreen = p.color === "var(--g-a)"; + const isAmber = p.color === "var(--amber)"; + const bg = isGreen + ? "var(--g-a)" + : isAmber + ? "var(--amber)" + : "var(--border-hi)"; + return ( +
    0 ? "+" : ""}${p.delta})` : ""}`} + style={{ + flex: 1, + height: `${h}%`, + borderRadius: "3px 3px 0 0", + background: bg, + opacity: p.dir === "flat" ? 0.55 : 0.9, + }} + /> + ); + })} +
    + + {/* clock rail β€” lock -> now, mirrors the mockup's TIP/NOW */} +
    + + LOCK {tl.firstLine} + + + NOW {tl.lastLine} + +
    +
    + ); +} diff --git a/web/src/lib/gradeShift.js b/web/src/lib/gradeShift.js new file mode 100644 index 0000000..75436b3 --- /dev/null +++ b/web/src/lib/gradeShift.js @@ -0,0 +1,94 @@ +/* ============================================================ + VYNDR β€” LIVE GRADE-SHIFT timeline helper (Wave 4B). + Pure CommonJS so the client component imports it (allowJs) + AND the Jest suite requires it directly. + + Builds a grade/line-movement timeline from ALREADY-EMITTED data + (intradayRefreshService line history {t,line} + revised_from_grade). + It NEVER fabricates: absent/short history => { show:false }. + + COLOR LAW (mirrors ROW-GRAMMAR + StatStrip.LineSparkline): + line movement is green = net move TOWARD the graded side, + amber = AGAINST, dim = flat. Never red β€” nothing here is settled. + For an OVER the "toward" sign is the raw line delta; for an UNDER + it is inverted (a line dropping steams the under). + ============================================================ */ + +const TOWARD_COLOR = 'var(--g-a)'; // green +const AGAINST_COLOR = 'var(--amber)'; // amber +const FLAT_COLOR = 'var(--text-1)'; // dim β€” never red +const MIN_POINTS = 3; // self-hide below this (same floor as LineSparkline) + +function isUnder(side) { + return String(side || 'O').toUpperCase().startsWith('U'); +} + +/** Classify a signed line delta relative to the graded side. */ +function classifyMove(delta, side) { + const d = typeof delta === 'number' && Number.isFinite(delta) ? delta : 0; + const toward = isUnder(side) ? -d : d; + if (toward > 0) return { dir: 'toward', color: TOWARD_COLOR }; + if (toward < 0) return { dir: 'against', color: AGAINST_COLOR }; + return { dir: 'flat', color: FLAT_COLOR }; +} + +/** Keep only real {t, line} points (strict number guard β€” Number(null)===0). */ +function cleanHistory(history) { + if (!Array.isArray(history)) return []; + return history + .filter((pt) => pt && typeof pt.line === 'number' && Number.isFinite(pt.line)) + .map((pt) => ({ t: pt.t != null ? String(pt.t) : '', line: pt.line })); +} + +/** + * Build the grade-shift timeline. + * prop: { history:[{t,line}], side, grade, revisedFrom|revised_from_grade, gradedLine } + * -> { show, points:[{t,line,delta,dir,color}], net:{delta,dir,color}, + * revision:{from,to}|null, firstLine, lastLine } + */ +function buildGradeTimeline(prop = {}) { + const history = cleanHistory(prop.history); + const show = history.length >= MIN_POINTS; + const side = prop.side; + const revisedFrom = prop.revisedFrom || prop.revised_from_grade || null; + const grade = prop.grade || null; + + const points = history.map((pt, i) => { + if (i === 0) return { t: pt.t, line: pt.line, delta: 0, dir: 'flat', color: FLAT_COLOR }; + const delta = Math.round((pt.line - history[i - 1].line) * 100) / 100; + const cls = classifyMove(delta, side); + return { t: pt.t, line: pt.line, delta, dir: cls.dir, color: cls.color }; + }); + + let net = { delta: 0, dir: 'flat', color: FLAT_COLOR }; + if (history.length >= 2) { + const raw = Math.round((history[history.length - 1].line - history[0].line) * 100) / 100; + const cls = classifyMove(raw, side); + net = { delta: raw, dir: cls.dir, color: cls.color }; + } + + // A revision is real only when a prior grade was preserved AND it differs. + const revision = revisedFrom && grade && String(revisedFrom) !== String(grade) + ? { from: String(revisedFrom), to: String(grade) } + : null; + + return { + show, + points, + net, + revision, + firstLine: history.length ? history[0].line : null, + lastLine: history.length ? history[history.length - 1].line : null, + }; +} + +module.exports = { + buildGradeTimeline, + classifyMove, + cleanHistory, + isUnder, + TOWARD_COLOR, + AGAINST_COLOR, + FLAT_COLOR, + MIN_POINTS, +}; diff --git a/web/src/lib/routes.js b/web/src/lib/routes.js index a9f3228..dc1a5f3 100644 --- a/web/src/lib/routes.js +++ b/web/src/lib/routes.js @@ -35,6 +35,10 @@ const OPEN_ROUTES = [ '/dashboard', '/slate', '/scan', + /* Wave 4B β€” the Parlay Lab is the parlay-building funnel: anon/free reach it + (free 2-leg cap + payout-blur upsell), same monetization logic as scan + + dashboard, so it stays OPEN, not gated. */ + '/parlay', '/compare', '/game', '/pricing', From bc8633466cf6dd92e7febdc9d8577bc65487a2f2 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 14:59:44 -0400 Subject: [PATCH 10/15] Wave 4A: Outlook Mode (never-empty grid) + Market-Breadth consensus strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 β€” OUTLOOK MODE. The game grid no longer dead-ends in a "NO SLATE" CTA. When there are no live games (and it's not a network failure) it shows REAL, always-available data: yesterday's PROVEN A-tier receipts (/api/ledger/model) + tomorrow's date-pinned ESPN schedule preview (free/cached). A network fetchError stays a distinct ERROR state β€” never a fabricated outlook. - lib/outlook.js (new, CommonJS, unit-tested): buildOutlook selection + mapTomorrowPreview (upcoming-only, drops incomplete matchups, never invents). - Slate.tsx: OutlookSurface replaces the empty-grid CTA (dateOffset 0 only). - dashboard/page.tsx: DashboardOutlook replaces the "Today's games" NO-SLATE CTA. Step 4 β€” MARKET-BREADTH / CONSENSUS vs MODEL. Makes the DeskShowcase "consensus vs model" claim REAL. Consensus = median book line across a prop's per-book rows; the model's position is model_value vs consensus, signed by the graded side. <2 distinct books β†’ null (never fabricate a consensus); a non-numeric line is ignored, never coerced to 0. - lib/marketBreadth.js (new, CommonJS, unit-tested): median/computeBreadth/ collectBreadth (strict null guards). - components/vyndr/MarketBreadth.tsx (new): mono/tabular strip, colored by sign via colorContract.edgeColor, self-hides when nothing has >=2 books. - Slate.tsx renders it above the grid (joins books + snapshot model_value). - slateAdapter.js exports gradeKey for the join. - DeskShowcase.tsx: the consensus claim is now backed by the shipped feature. Tests: tests/unit/outlook.test.js + tests/unit/marketBreadth.test.js (23 cases). Full suite 2984 passing (245 suites); next build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/unit/marketBreadth.test.js | 115 ++++++++++ tests/unit/outlook.test.js | 95 ++++++++ web/src/app/dashboard/page.tsx | 113 ++++++++-- web/src/app/pricing/DeskShowcase.tsx | 6 + web/src/components/Slate.tsx | 241 +++++++++++++++++---- web/src/components/vyndr/MarketBreadth.tsx | 113 ++++++++++ web/src/components/vyndr/index.ts | 1 + web/src/lib/marketBreadth.js | 114 ++++++++++ web/src/lib/outlook.js | 78 +++++++ web/src/lib/slateAdapter.js | 1 + 10 files changed, 824 insertions(+), 53 deletions(-) create mode 100644 tests/unit/marketBreadth.test.js create mode 100644 tests/unit/outlook.test.js create mode 100644 web/src/components/vyndr/MarketBreadth.tsx create mode 100644 web/src/lib/marketBreadth.js create mode 100644 web/src/lib/outlook.js diff --git a/tests/unit/marketBreadth.test.js b/tests/unit/marketBreadth.test.js new file mode 100644 index 0000000..973b144 --- /dev/null +++ b/tests/unit/marketBreadth.test.js @@ -0,0 +1,115 @@ +// Wave 4A β€” MARKET-BREADTH / CONSENSUS strip (Step 4). Makes the DeskShowcase +// "consensus vs model" claim REAL: the consensus is the MEDIAN book line across +// the prop's per-book rows; the model's position is model_value vs that +// consensus, signed by the graded side. Doctrine: never fabricate a consensus β€” +// <2 distinct books β†’ null (absent beats invented); a non-numeric line is +// ignored, never coerced to 0 (the Number(null)===0 trap). + +const { computeBreadth, collectBreadth, median } = require('../../web/src/lib/marketBreadth'); + +describe('median', () => { + it('odd length β†’ middle', () => { expect(median([2.5, 1.5, 3.5])).toBe(2.5); }); + it('even length β†’ mean of the two middles', () => { expect(median([1.5, 2.5])).toBe(2); }); + it('ignores non-finite, empty β†’ null', () => { + expect(median([NaN, 1.5, 2.5, null])).toBe(2); + expect(median([])).toBeNull(); + expect(median(null)).toBeNull(); + }); +}); + +describe('computeBreadth β€” median consensus from β‰₯2 books', () => { + const books = [ + { book: 'draftkings', line: 2.5, over_odds: -115 }, + { book: 'fanduel', line: 2.5, over_odds: -110 }, + { book: 'betmgm', line: 1.5, over_odds: -120 }, + ]; + + it('consensus is the median book LINE; model above β†’ OVER edge (positive, green)', () => { + const b = computeBreadth(books, 3.0, 'over'); + expect(b.consensus).toBe(2.5); + expect(b.bookCount).toBe(3); + expect(b.model).toBe(3.0); + expect(b.delta).toBeCloseTo(0.5); + expect(b.signedEdge).toBeCloseTo(0.5); // model projects ABOVE market β†’ supports OVER + expect(b.position).toBe('above'); + expect(b.side).toBe('over'); + }); + + it('UNDER read: model BELOW consensus is the edge (signed positive)', () => { + const b = computeBreadth(books, 2.0, 'under'); + expect(b.consensus).toBe(2.5); + expect(b.delta).toBeCloseTo(-0.5); // raw model - consensus stays signed to the market + expect(b.signedEdge).toBeCloseTo(0.5); // consensus - model, favors UNDER + expect(b.position).toBe('below'); + expect(b.side).toBe('under'); + }); + + it('model behind the market on an OVER β†’ negative signed edge (amber/red)', () => { + const b = computeBreadth(books, 2.0, 'over'); + expect(b.signedEdge).toBeCloseTo(-0.5); + expect(b.position).toBe('below'); + }); + + it('<2 DISTINCT books β†’ null (never fabricate a consensus)', () => { + expect(computeBreadth([{ book: 'dk', line: 2.5 }], 3, 'over')).toBeNull(); + // same book twice is still one opinion β†’ not a consensus + expect(computeBreadth([{ book: 'dk', line: 2.5 }, { book: 'dk', line: 1.5 }], 3, 'over')).toBeNull(); + expect(computeBreadth([], 3)).toBeNull(); + expect(computeBreadth(null, 3)).toBeNull(); + }); + + it('absent model β†’ real consensus present, comparison null (no invented model)', () => { + const b = computeBreadth(books, null, 'over'); + expect(b.consensus).toBe(2.5); + expect(b.bookCount).toBe(3); + expect(b.model).toBeNull(); + expect(b.delta).toBeNull(); + expect(b.signedEdge).toBeNull(); + expect(b.position).toBeNull(); + }); + + it('a non-numeric book line is IGNORED, not coerced to 0', () => { + const mixed = [{ book: 'dk', line: 2.5 }, { book: 'fd', line: null }, { book: 'mgm', line: 2.5 }]; + const b = computeBreadth(mixed, 2.5, 'over'); + expect(b.bookCount).toBe(2); + expect(b.consensus).toBe(2.5); + expect(b.position).toBe('inline'); // model == consensus + expect(b.signedEdge).toBe(0); + }); +}); + +describe('collectBreadth β€” ranked list, self-hiding', () => { + it('drops <2-book props and ranks by |signedEdge| desc', () => { + const items = [ + { player: 'A', stat: 'hits', side: 'over', line: 1.5, modelValue: 2.0, books: [{ book: 'dk', line: 1.5 }, { book: 'fd', line: 1.5 }] }, + { player: 'B', stat: 'tb', side: 'over', line: 2.5, modelValue: 2.6, books: [{ book: 'dk', line: 2.5 }, { book: 'fd', line: 2.5 }] }, + { player: 'C', stat: 'ks', side: 'over', line: 5.5, modelValue: 6, books: [{ book: 'dk', line: 5.5 }] }, // 1 book β†’ dropped + ]; + const out = collectBreadth(items, 6); + expect(out).toHaveLength(2); + expect(out[0].player).toBe('A'); // |0.5| edge beats |0.1| + expect(out[1].player).toBe('B'); + }); + + it('empty when nothing qualifies (component self-hides)', () => { + expect(collectBreadth([{ player: 'C', stat: 'ks', side: 'over', books: [{ book: 'dk', line: 5.5 }] }], 6)).toEqual([]); + expect(collectBreadth(null)).toEqual([]); + expect(collectBreadth([])).toEqual([]); + }); +}); + +describe('source: MarketBreadth component self-hides + colors by sign', () => { + const fs = require('fs'); + const path = require('path'); + const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8'); + + it('MarketBreadth.tsx colors the edge via the color contract (edgeColor), not raw green', () => { + const src = read('components/vyndr/MarketBreadth.tsx'); + expect(src).toContain('edgeColor'); + }); + + it('MarketBreadth.tsx self-hides when there is nothing to show', () => { + const src = read('components/vyndr/MarketBreadth.tsx'); + expect(src).toMatch(/return null/); + }); +}); diff --git a/tests/unit/outlook.test.js b/tests/unit/outlook.test.js new file mode 100644 index 0000000..c35be82 --- /dev/null +++ b/tests/unit/outlook.test.js @@ -0,0 +1,95 @@ +// Wave 4A β€” OUTLOOK MODE (never-empty slate grid, Step 3). +// The game grid must NEVER dead-end in a "NO SLATE" CTA. When there are no +// live games it falls back to REAL always-available data: yesterday's PROVEN +// A-tier receipts + tomorrow's date-pinned schedule preview. A network +// fetchError stays an ERROR state (a fetch failure is never a fake outlook). + +const { buildOutlook, mapTomorrowPreview } = require('../../web/src/lib/outlook'); + +describe('mapTomorrowPreview β€” real schedule β†’ preview rows', () => { + const games = [ + { id: 'g1', awayTeam: { name: 'Yankees' }, homeTeam: { name: 'Red Sox' }, gameTime: '2026-07-14T23:00:00Z', status: 'pre', sport: 'mlb' }, + { id: 'g2', awayTeam: { abbreviation: 'LAD' }, homeTeam: { abbreviation: 'SF' }, status: 'in' }, // live β†’ dropped (a preview is upcoming only) + { id: 'g3', awayTeam: {}, homeTeam: { name: 'X' } }, // missing away team β†’ dropped (never fabricate a matchup) + { id: 'g4', awayTeam: { name: 'Cubs' }, homeTeam: { name: 'Cards' }, status: 'post' }, // finished β†’ dropped + ]; + + it('maps upcoming games only, dropping live / finished / incomplete', () => { + const out = mapTomorrowPreview(games, 8); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ id: 'g1', away: 'Yankees', home: 'Red Sox', sport: 'MLB' }); + }); + + it('caps at the limit and never fabricates a matchup from bad input', () => { + const many = Array.from({ length: 10 }, (_, i) => ({ id: `g${i}`, awayTeam: { name: `A${i}` }, homeTeam: { name: `H${i}` }, status: 'pre' })); + expect(mapTomorrowPreview(many, 3)).toHaveLength(3); + expect(mapTomorrowPreview(null, 5)).toEqual([]); + expect(mapTomorrowPreview(undefined, 5)).toEqual([]); + }); + + it('accepts the flat {away,home} shape too (dashboard schedule mapping)', () => { + const out = mapTomorrowPreview([{ id: 'z', away: 'Mets', home: 'Phillies', start_time: '2026-07-14T18:00:00Z' }], 8); + expect(out[0]).toMatchObject({ away: 'Mets', home: 'Phillies' }); + }); +}); + +describe('buildOutlook β€” never-empty selection', () => { + const settled = [ + { player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A+', outcome: 'hit', actual_value: 3, sport: 'mlb' }, + { player_name: 'Low Grade', stat: 'hits', line: 0.5, side: 'over', grade: 'C', outcome: 'hit' }, // not A-tier β†’ not a receipt + { player_name: 'The Miss', stat: 'hits', line: 1.5, side: 'over', grade: 'A', outcome: 'miss' }, // a miss never becomes proof + ]; + const tomorrow = [{ id: 't1', awayTeam: { name: 'Mets' }, homeTeam: { name: 'Braves' }, status: 'pre', sport: 'mlb' }]; + + it('a network fetchError stays an ERROR state β€” never a fabricated outlook', () => { + expect(buildOutlook({ gamesCount: 0, fetchError: 'No games available right now.', settledRows: settled, tomorrow })).toEqual({ mode: 'error' }); + }); + + it('live games on the board β†’ live mode (outlook not shown)', () => { + expect(buildOutlook({ gamesCount: 3, settledRows: settled, tomorrow })).toEqual({ mode: 'live' }); + }); + + it('no games β†’ outlook carrying PROVEN receipts AND tomorrow preview (never blank)', () => { + const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow }); + expect(o.mode).toBe('outlook'); + expect(o.receipts).toHaveLength(1); + expect(o.receipts[0].player).toBe('Aaron Judge'); + expect(o.receipts[0].outcome).toBe('hit'); + expect(o.tomorrow).toHaveLength(1); + expect(o.tomorrow[0].home).toBe('Braves'); + expect(o.hasContent).toBe(true); + }); + + it('no games, no proof, no schedule β†’ STILL an outlook surface (the header carries it), never error / never blank', () => { + const o = buildOutlook({ gamesCount: 0, settledRows: [], tomorrow: [] }); + expect(o.mode).toBe('outlook'); + expect(o.receipts).toEqual([]); + expect(o.tomorrow).toEqual([]); + expect(o.hasContent).toBe(false); + }); + + it('receipts alone (no tomorrow schedule) still fills the grid', () => { + const o = buildOutlook({ gamesCount: 0, settledRows: settled, tomorrow: [] }); + expect(o.mode).toBe('outlook'); + expect(o.receipts).toHaveLength(1); + expect(o.tomorrow).toEqual([]); + expect(o.hasContent).toBe(true); + }); +}); + +// The dead-end CTA cards must be gone: the grid now renders an Outlook surface. +describe('source: the slate + dashboard render the Outlook surface (not a NO-SLATE dead-end)', () => { + const fs = require('fs'); + const path = require('path'); + const read = (rel) => fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', rel), 'utf8'); + + it('Slate.tsx wires buildOutlook / the Outlook surface into the empty branch', () => { + const src = read('components/Slate.tsx'); + expect(src).toMatch(/OutlookSurface|buildOutlook/); + }); + + it('dashboard renders the Outlook surface instead of the "NO SLATE" CTA', () => { + const src = read('app/dashboard/page.tsx'); + expect(src).toMatch(/OutlookSurface|buildOutlook|mapTomorrowPreview/); + }); +}); diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 49288af..d9682da 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -19,6 +19,9 @@ import { nextRunLabelET } from '@/lib/pipelineSchedule'; // varying signal, and fall back to yesterday's PROVEN A-tier receipts so first // paint ALWAYS proves the model (#1, #13, Part 6). import { selectTopGrades, buildHeroReceipts } from '@/lib/slateAdapter'; +// Wave 4A (Step 3) β€” OUTLOOK MODE: the "Today's games" empty branch is never a +// dead-end CTA; it shows yesterday's proven receipts + tomorrow's real schedule. +import { buildOutlook } from '@/lib/outlook'; import GradeBadge from '@/components/vyndr/GradeBadge'; import { currentAccessToken } from '@/lib/authToken'; @@ -109,6 +112,98 @@ const SPORT_COLOR: Record = { WNBA: '#FFB347', }; +/** + * Wave 4A (Step 3) β€” the dashboard OUTLOOK surface for an empty slate. Replaces + * the old "NO SLATE" dead-end CTA: the month-aware header + yesterday's PROVEN + * A-tier receipts + tomorrow's date-pinned schedule preview (all REAL, + * always-available data β€” never an invented line). Self-fetches both; the + * header always renders, so the grid is never blank. + */ +function DashboardOutlook({ sport }: { sport: Sport }) { + const [settled, setSettled] = useState(null); + const [tomorrow, setTomorrow] = useState(null); + + useEffect(() => { + let active = true; + fetch(`/api/ledger/model?sport=${sport.toLowerCase()}&limit=80`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); }) + .catch(() => { if (active) setSettled([]); }); + return () => { active = false; }; + }, [sport]); + + useEffect(() => { + let active = true; + // Tomorrow, ET β€” the schedule route is date-pinned (free / cached). + const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(Date.now() + 86_400_000)); + fetch(`/api/schedule/${sport.toLowerCase()}?date=${date}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (active) setTomorrow((Array.isArray(d?.games) ? d.games : []).map((g: ScheduleApiGame) => ({ ...g, sport: sport.toLowerCase() }))); }) + .catch(() => { if (active) setTomorrow([]); }); + return () => { active = false; }; + }, [sport]); + + const outlook = buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceiptView[]; tomorrow?: OutlookGameView[] }; + const receipts: OutlookReceiptView[] = outlook.receipts ?? []; + const preview: OutlookGameView[] = outlook.tomorrow ?? []; + const { title, body } = emptyStateCopy(sport.toLowerCase()); + + return ( +
    +
    +

    OUTLOOK

    +

    {title}

    +

    {body}

    +
    + + {receipts.length > 0 && ( +
    +
    YESTERDAY Β· PROVEN
    +
    + {receipts.map((r: OutlookReceiptView, i: number) => ( +
    +
    + {String(r.sport).toUpperCase()} + +
    +
    {r.player}
    +

    + {String(r.side).toUpperCase().startsWith('U') ? 'under' : 'over'} {r.line} {String(r.stat).replace(/_/g, ' ')} +

    +

    + ✓ HIT{r.actual != null ? ` (${r.actual})` : ''} +

    +
    + ))} +
    +
    + )} + + {preview.length > 0 && ( +
    +
    TOMORROW Β· SCHEDULE β€” lines post on the day
    +
    + {preview.map((g: OutlookGameView) => ( +
    + {g.away} @ {g.home} + {g.time && {formatTime(g.time)}} +
    + ))} +
    +
    + )} + + +
    + ); +} + +// Render-only views of the buildOutlook output (the JS adapter has no TS types). +interface OutlookReceiptView { player: string; stat: string; line: number; side: string; grade: string; sport: string; actual: number | null } +interface OutlookGameView { id: string; away: string; home: string; time: string | null } + export default function DashboardPage() { const router = useRouter(); const { user, session, tier, scansRemaining, loading: authLoading } = useAuth(); @@ -431,21 +526,9 @@ export default function DashboardPage() { {games === null ? ( ) : games.length === 0 ? ( - // Session 57 (Phase 0) β€” honest per-sport empty state (spec Β§6): - // an off-season sport says when it returns; in-season = off-day. -
    -

    NO SLATE

    -

    {emptyStateCopy(sport.toLowerCase()).title}

    -

    - {emptyStateCopy(sport.toLowerCase()).body} -

    - -
    + // Wave 4A (Step 3) β€” OUTLOOK MODE: never a dead-end "NO SLATE" CTA. + // Yesterday's proven receipts + tomorrow's real schedule fill the grid. + ) : (
    {games.map((g, idx) => ( diff --git a/web/src/app/pricing/DeskShowcase.tsx b/web/src/app/pricing/DeskShowcase.tsx index 9e9e236..31c0b8f 100644 --- a/web/src/app/pricing/DeskShowcase.tsx +++ b/web/src/app/pricing/DeskShowcase.tsx @@ -83,6 +83,12 @@ export default function DeskShowcase() {
    + {/* Wave 4A (Step 4) β€” this claim is now BACKED by a real feature: the + CONSENSUS vs MODEL strip (components/vyndr/MarketBreadth, fed by + lib/marketBreadth.collectBreadth) ships on the live slate/dashboard, + computing the median book line vs the model's projection. "live + line moves" is the existing snapshot line-deltas / LineSparkline. + No longer an empty promise β€” do not remove without removing those. */}
    diff --git a/web/src/components/Slate.tsx b/web/src/components/Slate.tsx index 2ab4b71..b3192f4 100644 --- a/web/src/components/Slate.tsx +++ b/web/src/components/Slate.tsx @@ -7,7 +7,14 @@ import { useRouter } from 'next/navigation'; import VyndrGameCard, { type GameCardData } from '@/components/vyndr/GameCard'; import type { SlateSport, GameLines, GameStreak } from '@/components/GameCard'; import { PropRowProp, Tier } from '@/components/PropRow'; -import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams } from '@/lib/slateAdapter'; +import { isRelevantGame, detectBestLines, indexGrades, indexDeltas, buildPlayerStripsFromProps, formatGameTime, buildPitcherMap, pitchersForGameTeams, gradeKey } from '@/lib/slateAdapter'; +// Wave 4A (Step 3) β€” OUTLOOK MODE: the never-empty grid. When there are no +// live games (and it's not a fetch failure) the grid shows REAL data β€” +// yesterday's proven receipts + tomorrow's date-pinned schedule. +import { buildOutlook, mapTomorrowPreview } from '@/lib/outlook'; +// Wave 4A (Step 4) β€” CONSENSUS vs MODEL: median-book-line vs the model. +import { collectBreadth } from '@/lib/marketBreadth'; +import MarketBreadth from '@/components/vyndr/MarketBreadth'; // A1 S11 β€” LIVE SLATE MODE: pure live-tracking join + proximity sort. // Grades never change in-game; these marks are tracking, labeled as such. import { buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst } from '@/lib/liveProgress'; @@ -165,7 +172,7 @@ interface StreakApiRow { interface StreaksResponse { streaks?: StreakApiRow[] } // Session 45 β€” pre-graded snapshot response (snapshot:{sport}:latest). -interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null } +interface SnapshotGrade { player?: string; player_name?: string; stat_type?: string; line?: number; direction?: string; grade?: string; archetype?: string | null; projection?: number | null; model_value?: number | null; gradedAt?: { line: number; odds?: number | null; timestamp?: string } | null } interface SnapshotDelta { player?: string; stat?: string; side?: string; delta?: number; direction?: string; currentLine?: number } interface SnapshotResponse { grades?: SnapshotGrade[]; deltas?: SnapshotDelta[] } @@ -419,6 +426,119 @@ function YesterdaySettle({ date }: { date: string }) { ); } +// The /api/ledger/model settled-row shape (subset the receipts read). +interface ModelReceiptRow { + player_name?: string; player?: string; sport?: string; stat?: string; + line?: number; side?: string; grade?: string; + outcome?: string | null; actual_value?: number | null; clv_result?: string | null; +} +// buildHeroReceipts output (proven yesterday hit). +interface OutlookReceipt { player: string; stat: string; line: number; side: string; grade: string; sport: string; outcome: string; actual: number | null; clvResult: string | null } +// mapTomorrowPreview output. +interface OutlookGame { id: string; away: string; home: string; time: string | null; sport: string | null } + +/** + * Wave 4A (Step 3) β€” OUTLOOK MODE surface. Renders in the empty game grid in + * place of the old dead-end CTA: yesterday's PROVEN A-tier receipts + + * tomorrow's date-pinned schedule preview (both REAL, always-available data β€” + * never an invented line). The month-aware header ALWAYS shows, so the grid is + * never blank. Distinct from the network `fetchError` state. + */ +function OutlookSurface({ tab }: { tab: SlateTab }) { + const [settled, setSettled] = useState(null); + const [tomorrow, setTomorrow] = useState(null); + + useEffect(() => { + let active = true; + const sportQ = tab !== 'all' && tab !== 'soccer' ? `&sport=${tab}` : ''; + fetch(`/api/ledger/model?limit=80${sportQ}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { if (active) setSettled(Array.isArray(d?.entries) ? d.entries : []); }) + .catch(() => { if (active) setSettled([]); }); + return () => { active = false; }; + }, [tab]); + + useEffect(() => { + let active = true; + const date = etDateWithOffset(1); // tomorrow, ET β€” the schedule route is date-pinned + const SPORTS: SlateSport[] = tab === 'all' + ? ['mlb', 'nba', 'wnba'] + : (['nba', 'wnba', 'mlb'] as string[]).includes(tab) ? [tab as SlateSport] : []; + if (SPORTS.length === 0) { setTomorrow([]); return; } + Promise.all(SPORTS.map(async (sport) => { + try { + const r = await fetch(`/api/schedule/${sport}?date=${date}`, { cache: 'no-store' }); + if (!r.ok) return [] as ScheduleGame[]; + const d = (await r.json()) as ScheduleResponse; + return (Array.isArray(d?.games) ? d.games : []).map((g) => ({ ...g, sport })); + } catch { return [] as ScheduleGame[]; } + })).then((lists) => { if (active) setTomorrow(lists.flat()); }); + return () => { active = false; }; + }, [tab]); + + const outlook = useMemo( + () => buildOutlook({ gamesCount: 0, settledRows: settled || [], tomorrow: tomorrow || [] }) as { mode: string; receipts?: OutlookReceipt[]; tomorrow?: OutlookGame[] }, + [settled, tomorrow], + ); + const receipts: OutlookReceipt[] = outlook.receipts ?? []; + const preview: OutlookGame[] = outlook.tomorrow ?? []; + const { title, body } = emptyStateCopy(tab); + + return ( +
    +
    +

    OUTLOOK

    +

    {title}

    +

    {body}

    +
    + + {receipts.length > 0 && ( +
    +
    YESTERDAY Β· PROVEN
    +
    + {receipts.map((r, i) => ( +
    +
    + {String(r.sport || '').toUpperCase()} + {r.grade} +
    +
    {r.player}
    +
    + {String(r.side).toUpperCase().startsWith('U') ? 'u' : 'o'}{r.line} {String(r.stat).replace(/_/g, ' ')} +
    +
    + βœ“ HIT{r.actual != null ? ` (${r.actual})` : ''}{r.clvResult === 'beat' ? ' Β· CLV BEAT' : ''} +
    +
    + ))} +
    +
    + )} + + {preview.length > 0 && ( +
    +
    + TOMORROW Β· SCHEDULE β€” lines post on the day +
    +
    + {preview.map((g) => ( +
    + {g.sport && {g.sport}} + {g.away} @ {g.home} + {g.time && {formatGameTime(g.time)}} +
    + ))} +
    +
    + )} +
    + ); +} + export interface SlateProps { initialTab?: SlateTab; tier?: Tier; @@ -742,6 +862,31 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook )) as SlateGame[]; }, [filteredGames, gradeIndex, liveIndex]); + // Wave 4A (Step 4) β€” CONSENSUS vs MODEL breadth. For every graded prop that + // carries β‰₯2 book lines, compare the market's median line to the model's + // projection (signed by the graded side). collectBreadth drops <2-book props + // and ranks by |edge| β€” an empty result self-hides the strip. This is the + // REAL data behind the DeskShowcase "consensus vs model" claim. + const breadthItems = useMemo(() => { + const items: Array<{ player: string; stat: string; side: string; line: number; books: PropRowProp['books']; modelValue: number | null }> = []; + for (const g of filteredGames) { + for (const p of g.props) { + if (!Array.isArray(p.books) || p.books.length < 2) continue; + const grade = (gradeIndex as Record)[gradeKey(p.player, p.stat_type)]; + const mv = grade ? (grade.projection ?? grade.model_value ?? null) : null; + items.push({ + player: p.player, + stat: p.stat_type, + side: (grade && grade.direction) || p.direction || 'over', + line: p.line, + books: p.books, + modelValue: mv == null ? null : Number(mv), + }); + } + } + return collectBreadth(items, 6); + }, [filteredGames, gradeIndex]); + // Session 25 β€” per-sport game counts for the tab labels, derived from // the MERGED list (schedule + odds), so a tab reads "MLB (8)" off the // free ESPN schedule even when odds are empty. Counts only appear for @@ -965,7 +1110,8 @@ export default function Slate({ initialTab = 'all', tier = 'free', preferredBook
    )} - {!loading && !fetchError && filteredGames.length === 0 && ( + {/* A search miss keeps its own scan-it CTA. */} + {!loading && !fetchError && filteredGames.length === 0 && searchQuery && (
    - {searchQuery ? ( - <> -

    - No props found for “{searchQuery}”. -

    - - Scan it manually β†’ - - - ) : ( - // Session 57 (Phase 0) β€” honest per-sport empty copy (spec Β§6): - // off-season sports name their return window; in-season = off-day. - (() => { - const { title, body } = emptyStateCopy(tab); - return ( - <> -

    {title}

    -

    {body}

    - - ); - })() - )} +

    + No props found for “{searchQuery}”. +

    + + Scan it manually β†’ + +
    + )} + + {/* Wave 4A (Step 3) β€” OUTLOOK MODE. Today's grid is never a dead-end CTA: + when there are no live games (and no search, no fetch failure) it shows + yesterday's proven receipts + tomorrow's real schedule. Yesterday/ + Tomorrow date nav keep the plain honest copy (those are explicit date + surfaces; -1 already has THE SETTLE below). */} + {!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset === 0 && ( + + )} + {!loading && !fetchError && filteredGames.length === 0 && !searchQuery && dateOffset !== 0 && ( +
    + {(() => { + const { title, body } = emptyStateCopy(tab); + return ( + <> +

    {title}

    +

    {body}

    + + ); + })()}
    )} {dateOffset === -1 && } + {/* Wave 4A (Step 4) β€” the CONSENSUS vs MODEL strip. Self-hides unless a + graded prop has a real β‰₯2-book median to compare the model against. */} + {dateOffset === 0 && } +
    {orderedGames.map((g, i) => ( = { + total_bases: 'TB', home_runs: 'HR', hits: 'H', rbi: 'RBI', runs: 'R', + strikeouts: 'K', hits_allowed: 'HA', earned_runs: 'ER', innings_pitched: 'IP', + stolen_bases: 'SB', points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT', +}; +function statLabel(stat?: string): string { + if (!stat) return ''; + return STAT_LABEL[stat] || String(stat).replace(/_/g, ' ').toUpperCase(); +} + +function fmtSigned(n: number | null): string { + if (n == null) return ''; + const s = n > 0 ? '+' : n < 0 ? '' : 'Β±'; + return `${s}${n}`; +} + +export default function MarketBreadth({ + items, + title = 'CONSENSUS vs MODEL', + max = 6, +}: { + items?: BreadthRow[] | null; + title?: string; + max?: number; +}) { + const rows = (Array.isArray(items) ? items : []).slice(0, Math.max(0, max)); + if (rows.length === 0) return null; // self-hide β€” no honest consensus to show + + return ( +
    + + {title} + + MEDIAN BOOK LINE Β· MODEL EDGE + + +
    + {rows.map((r, i) => { + const col = edgeColor(r.signedEdge); + const sideChar = r.side === 'under' ? 'u' : 'o'; + return ( +
    + {r.player && {r.player}} + + {statLabel(r.stat)} {sideChar}{r.line ?? r.consensus} + + + CONSENSUS {r.consensus} + Β· {r.bookCount} BOOKS + + {r.model != null ? ( + + MODEL {r.model} + {r.signedEdge != null && ( + Β· {fmtSigned(r.signedEdge)} + )} + + ) : ( + MODEL β€” + )} +
    + ); + })} +
    +
    + ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index abc0ddf..1dd53a3 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -9,6 +9,7 @@ export { default as Card } from './Card'; export { default as Sparkline } from './Sparkline'; export { default as Ticker } from './Ticker'; export { default as EmptyState } from './EmptyState'; +export { default as MarketBreadth } from './MarketBreadth'; export type { EmptyStateProps, EmptyStateAction } from './EmptyState'; export { default as GradeResultCard } from './GradeResultCard'; export type { GradeResultData } from './GradeResultCard'; diff --git a/web/src/lib/marketBreadth.js b/web/src/lib/marketBreadth.js new file mode 100644 index 0000000..d392fa9 --- /dev/null +++ b/web/src/lib/marketBreadth.js @@ -0,0 +1,114 @@ +/* ============================================================ + VYNDR β€” MARKET BREADTH / CONSENSUS-vs-MODEL (Wave 4A, Step 4). + + Makes the DeskShowcase "consensus vs model, live line moves" claim REAL. + Given a prop's per-book rows + the model's projected value, compute the + market CONSENSUS (median book line) and the model's position vs that + consensus, SIGNED by the graded side (so an over that the model projects + ABOVE the market and an under it projects BELOW both read as a positive + edge β†’ signal-green via the color contract). + + DATA-SEMANTICS RULE: VYNDR never invents a market number. A consensus is + only honest with β‰₯2 DISTINCT books posting a finite line β†’ otherwise null + (absent beats invented). A non-numeric line is IGNORED, never coerced to + 0 (the classic `Number(null) === 0` fabrication bug). + + Plain CommonJS so the .tsx strip imports it (allowJs) AND Jest exercises + the logic directly β€” same pattern as slateAdapter.js / colorContract.js. + ============================================================ */ + +/** Strict numeric parse β€” null (never 0) when a value isn't a real number. */ +function numOrNull(v) { + const n = typeof v === 'number' ? v : parseFloat(v); + return Number.isFinite(n) ? n : null; +} + +const round2 = (x) => Math.round(x * 100) / 100; + +/** Median of the finite numbers in `nums`. Empty / all-non-finite β†’ null. */ +function median(nums) { + const sorted = (Array.isArray(nums) ? nums : []) + .map(numOrNull) + .filter((n) => n != null) + .sort((a, b) => a - b); + if (sorted.length === 0) return null; + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : round2((sorted[mid - 1] + sorted[mid]) / 2); +} + +/** + * computeBreadth(books, modelValue, side) β†’ breadth | null. + * + * `books` = the prop's per-book rows ([{ book, line, over_odds, under_odds }]) + * β€” the same grouped shape the slate threads onto each prop. One opinion per + * DISTINCT book (dedupe by book name). <2 distinct books with a finite line + * β†’ null (no honest consensus). + * + * Returns: + * consensus β€” median book line (the market's line) + * bookCount β€” distinct books contributing a finite line + * model β€” the model's projected value (numOrNull) or null + * delta β€” model βˆ’ consensus (signed to the MARKET), or null + * signedEdge β€” signed to the GRADED SIDE (positive = model beats market), + * or null when the model value is absent + * position β€” 'above' | 'below' | 'inline' (model vs consensus), or null + * side β€” normalized 'over' | 'under' + */ +function computeBreadth(books, modelValue, side = 'over') { + const rows = Array.isArray(books) ? books : []; + const byBook = new Map(); + for (const r of rows) { + if (!r || !r.book) continue; + const ln = numOrNull(r.line); + if (ln == null) continue; + // First finite line per distinct book wins (one opinion per book). + if (!byBook.has(r.book)) byBook.set(r.book, ln); + } + if (byBook.size < 2) return null; // <2 books β†’ never fabricate a consensus + + const consensus = median([...byBook.values()]); + const isUnder = String(side || 'over').toLowerCase().startsWith('u'); + const model = numOrNull(modelValue); + + let delta = null; + let signedEdge = null; + let position = null; + if (model != null && consensus != null) { + delta = round2(model - consensus); + signedEdge = round2(isUnder ? consensus - model : model - consensus); + position = delta > 0 ? 'above' : delta < 0 ? 'below' : 'inline'; + } + + return { + consensus, + bookCount: byBook.size, + model, + delta, + signedEdge, + position, + side: isUnder ? 'under' : 'over', + }; +} + +/** + * collectBreadth(items, limit) β€” compute breadth for a list of props and + * return the qualifying rows ranked by |signedEdge| desc (the biggest model + * disagreements with the market lead). Non-qualifying props (<2 books) are + * dropped, so an empty result means the strip self-hides. + * + * `items` = [{ player, stat, side, line, books, modelValue }]. + */ +function collectBreadth(items, limit = 6) { + const out = []; + for (const it of Array.isArray(items) ? items : []) { + if (!it) continue; + const b = computeBreadth(it.books, it.modelValue, it.side); + if (!b) continue; + out.push({ player: it.player, stat: it.stat, line: numOrNull(it.line), ...b }); + } + const abs = (x) => Math.abs(numOrNull(x) == null ? 0 : numOrNull(x)); + out.sort((a, b) => abs(b.signedEdge) - abs(a.signedEdge)); + return out.slice(0, Math.max(0, limit)); +} + +module.exports = { median, computeBreadth, collectBreadth, numOrNull }; diff --git a/web/src/lib/outlook.js b/web/src/lib/outlook.js new file mode 100644 index 0000000..2f2fa13 --- /dev/null +++ b/web/src/lib/outlook.js @@ -0,0 +1,78 @@ +/* ============================================================ + VYNDR β€” OUTLOOK MODE (Wave 4A, Step 3): the never-empty slate grid. + + The game grid must NEVER dead-end in a "NO SLATE" CTA. When there are no + live games (and it's not a network failure) the grid falls back to REAL, + always-available data β€” the terminal is never dark, but it is never + fabricated either: + β€’ yesterday's PROVEN A-tier receipts (settled ledger hits), and/or + β€’ tomorrow's date-pinned ESPN schedule preview (free / cached). + + A network `fetchError` is DISTINCT and stays an ERROR state β€” a fetch + failure is never dressed up as a fake outlook. + + Plain CommonJS so the .tsx surfaces import it (allowJs) AND Jest exercises + the selection logic directly. + ============================================================ */ + +const { buildHeroReceipts } = require('./slateAdapter'); + +/** Statuses that mean a game is no longer a PREVIEW (already underway/done). */ +const NON_PREVIEW = new Set(['in', 'post', 'final', 'live', 'completed']); + +/** + * mapTomorrowPreview(scheduleGames, limit) β€” map real schedule games (either + * the ESPN `{ awayTeam, homeTeam, gameTime, status }` shape OR the flattened + * `{ away, home, start_time }` shape) β†’ compact preview rows. Upcoming only; + * a game missing either team is DROPPED (never fabricate a matchup). + */ +function mapTomorrowPreview(scheduleGames, limit = 8) { + const list = Array.isArray(scheduleGames) ? scheduleGames : []; + const out = []; + for (const g of list) { + if (!g) continue; + const status = String(g.status || g.state || '').toLowerCase(); + if (NON_PREVIEW.has(status)) continue; + const away = g.away || g.awayTeam?.name || g.awayTeam?.abbreviation || null; + const home = g.home || g.homeTeam?.name || g.homeTeam?.abbreviation || null; + if (!away || !home) continue; // absent beats a fabricated fixture + out.push({ + id: g.id || `${away}-${home}`, + away, + home, + time: g.gameTime || g.start_time || g.time || null, + sport: g.sport ? String(g.sport).toUpperCase() : null, + }); + if (out.length >= Math.max(0, limit)) break; + } + return out; +} + +/** + * buildOutlook(opts) β†’ + * { mode: 'error' } β€” a network failure + * { mode: 'live' } β€” real games on the board + * { mode: 'outlook', receipts, tomorrow, hasContent } + * + * The outlook surface is ALWAYS non-error / non-blank at the surface level: + * even with no receipts and no schedule, the caller still renders the + * month-aware empty-state header β€” the grid never dead-ends in a CTA. + * + * @param {{ gamesCount?: number, fetchError?: unknown, settledRows?: unknown[], tomorrow?: unknown[] }} [opts] + */ +function buildOutlook(opts = {}) { + const { gamesCount = 0, fetchError = null, settledRows = [], tomorrow = [] } = opts; + if (fetchError) return { mode: 'error' }; + if (Number(gamesCount) > 0) return { mode: 'live' }; + + const receipts = buildHeroReceipts(settledRows, 6); + const preview = mapTomorrowPreview(tomorrow, 8); + return { + mode: 'outlook', + receipts, + tomorrow: preview, + hasContent: receipts.length > 0 || preview.length > 0, + }; +} + +module.exports = { buildOutlook, mapTomorrowPreview }; diff --git a/web/src/lib/slateAdapter.js b/web/src/lib/slateAdapter.js index f0ba6f4..82c1b20 100644 --- a/web/src/lib/slateAdapter.js +++ b/web/src/lib/slateAdapter.js @@ -603,6 +603,7 @@ module.exports = { isRelevantGame, indexGrades, indexDeltas, + gradeKey, statShort, gradedAgo, buildPlayerStripsFromProps, From 76c289d4c12f5be97b499af5baa7738a7455083f Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 15:26:48 -0400 Subject: [PATCH 11/15] Wave 5A: /u house-mode public profile (D2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reserved house handle (default 'vyndr', env HOUSE_HANDLE) resolves to the PUBLIC model record β€” getModelAggregate() with no userId (user_id=NULL rows) β€” WITHOUT a public_profiles row. It is the ONLY special case; every other handle keeps the private-by-default, byte-identical-404 no-existence-leak contract. The house profile is always public and never 404s (a fetch failure degrades to an honest building state). - src/routes/profiles.js: house short-circuit + sendHouseProfile (public aggregate + by_tier + public settled entries), reserved before the publish lookup so a user claim is shadowed. - PublicProfile.tsx: house label 'VYNDR MODEL Β· PUBLIC RECORD' + hero/subtitle off data.house; keeps the CLV-VERIFIED record hero + TierRecord calibration + recent settled reads (misses included). - opengraph-image.tsx (1200x630): house-branded eyebrow/heading. - portrait/route.tsx: new 1080x1350 share crop (real aggregate or tagline fallback, never a fabricated number). - Discoverability: 'VIEW AS PUBLIC PAGE ->' on the ledger MODEL header + 'VIEW PUBLIC RECORD ->' under the landing ModelRecord, both to /u/vyndr. - tests/unit/houseProfile.test.js: house resolves to user_id=NULL aggregate (no public_profiles row) + by_tier; unknown/unpublished user handles stay byte-identical 404; page renders house label + TierRecord + portrait crop. 3019 tests green (3012 -> 3019); next build EXIT=0. Co-Authored-By: Claude Opus 4.8 (1M context) --- BACKEND_HANDOFF.md | 12 ++ src/routes/profiles.js | 63 ++++++++ tests/unit/houseProfile.test.js | 170 +++++++++++++++++++++ web/src/app/ledger/page.tsx | 12 ++ web/src/app/page.tsx | 11 +- web/src/app/u/[handle]/PublicProfile.tsx | 14 +- web/src/app/u/[handle]/opengraph-image.tsx | 8 +- web/src/app/u/[handle]/portrait/route.tsx | 117 ++++++++++++++ 8 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 tests/unit/houseProfile.test.js create mode 100644 web/src/app/u/[handle]/portrait/route.tsx diff --git a/BACKEND_HANDOFF.md b/BACKEND_HANDOFF.md index 24bc271..8fb645d 100644 --- a/BACKEND_HANDOFF.md +++ b/BACKEND_HANDOFF.md @@ -242,6 +242,18 @@ Published only. Unknown AND unpublished return the SAME 404 body } ``` +### HOUSE profile (Wave 5A, D2) +The reserved handle `HOUSE_HANDLE` (env, default `vyndr`) resolves to the +PUBLIC model record (`getModelAggregate()` with NO userId β†’ the `user_id=NULL` +ledger rows) WITHOUT a `public_profiles` row, and is ALWAYS public (the +partner-pitch weapon). Same response shape + two extra fields: +`{ ..., house: true, label: 'VYNDR MODEL Β· PUBLIC RECORD' }`. Entries are the +public settled rows (`user_id IS NULL`, misses included). Reserved before the +publish lookup β€” a user who claims it is shadowed. Every OTHER handle keeps the +private-by-default, byte-identical-404 contract. It never 404s (a fetch failure +degrades to an honest empty/building state). Portrait share crop: route handler +`GET /u/[handle]/portrait` β†’ 1080Γ—1350 PNG (real aggregate or tagline fallback). + ### `GET|POST /api/profiles/me` (requireAuth) GET β†’ `{ profile: { handle, published, created_at } | null }`. POST `{ handle, published }` β†’ upsert own row (service role). Handle must diff --git a/src/routes/profiles.js b/src/routes/profiles.js index 8e0f753..402c395 100644 --- a/src/routes/profiles.js +++ b/src/routes/profiles.js @@ -19,6 +19,13 @@ * PRIVACY: PRIVATE BY DEFAULT β€” `published` only flips via the explicit * toggle. NO EXISTENCE LEAK: an unknown handle and an unpublished handle * return the byte-identical 404 body. + * + * HOUSE PROFILE (Wave 5A, D2) β€” the reserved `HOUSE_HANDLE` (default `vyndr`) + * resolves to the PUBLIC model record (`getModelAggregate()` with NO userId β†’ + * the `user_id = NULL` ledger rows). It needs NO public_profiles row and is + * ALWAYS public (it's the partner-pitch weapon: the real house record). It is + * the ONLY special case; every OTHER handle keeps the private-by-default, + * no-existence-leak contract intact. */ const express = require('express'); @@ -30,6 +37,10 @@ const router = express.Router(); router.use(createRateLimit({ windowMs: 60_000, max: 60 })); const HANDLE_RE = /^[a-z0-9_]{3,20}$/; +// Reserved house handle β†’ the public model record (user_id = NULL). Operators +// can override via env; it must still satisfy HANDLE_RE to be reachable. +const HOUSE_HANDLE = String(process.env.HOUSE_HANDLE || 'vyndr').trim().toLowerCase(); +const HOUSE_LABEL = 'VYNDR MODEL Β· PUBLIC RECORD'; // Same 404 body for unknown AND unpublished β€” never confirm a handle exists. const NOT_FOUND = { error: 'Profile not found' }; // Same columns as /api/ledger (routes/ledger.js ROW_COLUMNS). @@ -94,6 +105,10 @@ router.get('/:handle', async (req, res) => { const handle = String(req.params.handle || '').trim().toLowerCase(); // Invalid shape can't exist (DB CHECK) β†’ same 404, no query needed. if (!HANDLE_RE.test(handle)) return res.status(404).json(NOT_FOUND); + // HOUSE handle β†’ the public model record. Checked BEFORE the + // public_profiles lookup so the handle is reserved (a user who claims it is + // shadowed). This is the ONLY handle that bypasses the publish gate. + if (handle === HOUSE_HANDLE) return sendHouseProfile(res); const sb = sbOrNull(); if (!sb) return res.status(404).json(NOT_FOUND); try { @@ -131,4 +146,52 @@ router.get('/:handle', async (req, res) => { } }); +/** + * The house/model profile β€” the PUBLIC model record (user_id = NULL), served + * as a shareable /u profile WITHOUT a public_profiles row. Same response shape + * the page already consumes (aggregate + by_tier + settled entries), plus + * `house: true` + a label so the UI can distinguish it from a user profile. + * It always "exists" β†’ a fetch failure degrades to an honest empty/building + * state, never a 404. + */ +async function sendHouseProfile(res) { + const sb = sbOrNull(); + try { + // NO userId β†’ the public `user_id = NULL` aggregate (the real house + // record + Wave-3 by_tier). getModelAggregate self-empties without env. + const aggregate = await ledgerService.getModelAggregate(sb ? { sb } : {}); + let entries = []; + if (sb) { + // ALL public settled reads, misses included β€” nothing curated. + const { data, error } = await sb.from('ledger_entries') + .select(ROW_COLUMNS) + .is('user_id', null) + .not('outcome', 'is', null) + .order('graded_at', { ascending: false }) + .limit(ENTRY_LIMIT); + if (error) throw new Error(error.message); + entries = data || []; + } + res.set('Cache-Control', 'public, max-age=60'); + return res.json({ + handle: HOUSE_HANDLE, + house: true, + label: HOUSE_LABEL, + aggregate, + entries, + min_sample: ledgerService.MIN_AGG_SAMPLE, + }); + } catch (err) { + console.error('[profiles/house]', err.message); + return res.status(200).json({ + handle: HOUSE_HANDLE, + house: true, + label: HOUSE_LABEL, + aggregate: null, + entries: [], + min_sample: ledgerService.MIN_AGG_SAMPLE, + }); + } +} + module.exports = router; diff --git a/tests/unit/houseProfile.test.js b/tests/unit/houseProfile.test.js new file mode 100644 index 0000000..751cf13 --- /dev/null +++ b/tests/unit/houseProfile.test.js @@ -0,0 +1,170 @@ +// Wave 5A (D2) β€” the HOUSE/model /u profile. The reserved house handle +// (`vyndr`) resolves to the PUBLIC model record (user_id = NULL) WITHOUT a +// public_profiles row β€” the partner-pitch weapon. Every OTHER handle keeps the +// private-by-default, no-existence-leak 404 (a byte-identical body for unknown +// AND unpublished). Supabase + auth mocked (profilesRoutes.test.js pattern) β€” +// no network, no live Supabase. + +const express = require('express'); +const request = require('supertest'); +const fs = require('fs'); +const path = require('path'); + +jest.mock('../../src/middleware/auth', () => ({ + requireAuth: (req, res, next) => { + if (!req.headers.authorization) return res.status(401).json({ error: 'auth required' }); + req.user = { id: 'u1', tier: 'analyst' }; + return next(); + }, +})); + +const mockState = { + profileRow: null, + ledgerRows: [], + filters: [], // [table, filters[]] +}; + +function mockChain(table) { + const b = { _filters: [] }; + const rec = (op) => (...args) => { b._filters.push([op, ...args]); return b; }; + b.select = () => b; + b.eq = rec('eq'); + b.is = rec('is'); + b.not = rec('not'); + b.gte = rec('gte'); + b.order = () => b; + b.maybeSingle = () => { + mockState.filters.push([table, b._filters]); + return Promise.resolve({ data: mockState.profileRow, error: null }); + }; + b.limit = () => { + mockState.filters.push([table, b._filters]); + return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 }); + }; + b.then = (resolve, reject) => { + mockState.filters.push([table, b._filters]); + return Promise.resolve({ data: mockState.ledgerRows, error: null, count: 0 }).then(resolve, reject); + }; + return b; +} + +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseServiceClient: () => ({ from: (table) => mockChain(table) }), +})); + +process.env.SUPABASE_URL = 'https://test.supabase.co'; +process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'; + +function mountApp() { + // resetModules forces profiles.js to re-read process.env.HOUSE_HANDLE (jest + // ignores require.cache deletes). The jest.mock factories re-apply. + jest.resetModules(); + const routes = require('../../src/routes/profiles'); + const app = express(); + app.use(express.json()); + app.use('/api/profiles', routes); + return app; +} + +beforeEach(() => { + mockState.profileRow = null; + mockState.ledgerRows.length = 0; + mockState.filters.length = 0; +}); + +describe('GET /api/profiles/vyndr β€” the HOUSE/model profile', () => { + test('resolves the public user_id=NULL aggregate with NO public_profiles row', async () => { + // Note: profileRow stays null β€” the house handle must NOT need a claim. + mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A', clv_result: 'beat', clv: 0.5 }); + const res = await request(mountApp()).get('/api/profiles/vyndr'); + + expect(res.status).toBe(200); + expect(res.body.handle).toBe('vyndr'); + expect(res.body.house).toBe(true); + expect(res.body.label).toBe('VYNDR MODEL Β· PUBLIC RECORD'); + expect(res.body.min_sample).toBe(20); + expect(res.body.aggregate).toBeTruthy(); + // Wave 3 per-tier calibration rides along inside the aggregate. + expect(res.body.aggregate.by_tier).toBeDefined(); + // n<20 gate honored: hit_pct is present but null (not a small-sample %). + expect(res.body.aggregate.hit_pct).toBeNull(); + expect(Array.isArray(res.body.entries)).toBe(true); + + // The public_profiles table was NEVER queried (handle is reserved). + expect(mockState.filters.filter(([t]) => t === 'public_profiles')).toHaveLength(0); + + // Every ledger query is the PUBLIC record (user_id IS NULL), never a user. + const ledgerQueries = mockState.filters.filter(([t]) => t === 'ledger_entries'); + expect(ledgerQueries.length).toBeGreaterThan(0); + for (const [, filters] of ledgerQueries) { + expect(filters.some((f) => f[0] === 'is' && f[1] === 'user_id' && f[2] === null)).toBe(true); + expect(filters.some((f) => f[0] === 'eq' && f[1] === 'user_id')).toBe(false); + } + // The entries list is settled rows only. + const entriesQ = ledgerQueries.find(([, f]) => f.some((x) => x[0] === 'not' && x[1] === 'outcome')); + expect(entriesQ).toBeTruthy(); + }); + + test('honors an env override for the house handle', async () => { + const prev = process.env.HOUSE_HANDLE; + process.env.HOUSE_HANDLE = 'house'; + try { + const res = await request(mountApp()).get('/api/profiles/house'); + expect(res.status).toBe(200); + expect(res.body.house).toBe(true); + // The default handle is no longer reserved β†’ falls through to 404. + const other = await request(mountApp()).get('/api/profiles/vyndr'); + expect(other.status).toBe(404); + } finally { + if (prev === undefined) delete process.env.HOUSE_HANDLE; else process.env.HOUSE_HANDLE = prev; + } + }); +}); + +describe('privacy preserved β€” the house handle is the ONLY special case', () => { + test('NO EXISTENCE LEAK β€” unknown and unpublished USER handles are byte-identical 404s', async () => { + mockState.profileRow = null; + const unknown = await request(mountApp()).get('/api/profiles/ghost_handle'); + + mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false }; + const unpublished = await request(mountApp()).get('/api/profiles/private_kev'); + + expect(unknown.status).toBe(404); + expect(unpublished.status).toBe(404); + expect(unknown.body).toEqual(unpublished.body); + expect(unknown.body).toEqual({ error: 'Profile not found' }); + }); + + test('an unpublished USER handle still leaks NO ledger data', async () => { + mockState.profileRow = { user_id: 'u3', handle: 'private_kev', published: false }; + mockState.ledgerRows.push({ id: 'r1', player_name: 'Judge', outcome: 'hit', grade: 'A' }); + const res = await request(mountApp()).get('/api/profiles/private_kev'); + expect(res.status).toBe(404); + expect(res.body.entries).toBeUndefined(); + expect(mockState.filters.filter(([t]) => t === 'ledger_entries')).toHaveLength(0); + }); +}); + +describe('/u/[handle] renders the house label + per-tier calibration', () => { + const WEB = path.join(__dirname, '..', '..', 'web', 'src'); + const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + + it('PublicProfile shows the house label off data.house + keeps TierRecord', () => { + const src = read('app/u/[handle]/PublicProfile.tsx'); + expect(src).toContain('VYNDR MODEL Β· PUBLIC RECORD'); + expect(src).toMatch(/data\.house/); + expect(src).toContain('TierRecord'); + }); + + it('has a portrait 1080x1350 share crop route', () => { + const src = read('app/u/[handle]/portrait/route.tsx'); + expect(src).toContain('1080'); + expect(src).toContain('1350'); + expect(src).not.toContain("runtime = 'edge'"); + }); + + it('is discoverable β€” a link to /u/vyndr on the ledger + landing record surfaces', () => { + expect(read('app/ledger/page.tsx')).toContain('/u/vyndr'); + expect(read('app/page.tsx')).toContain('/u/vyndr'); + }); +}); diff --git a/web/src/app/ledger/page.tsx b/web/src/app/ledger/page.tsx index 6a73355..2c46a37 100644 --- a/web/src/app/ledger/page.tsx +++ b/web/src/app/ledger/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; import { GradePill } from '@/components/GradeCard'; import { useAuth } from '@/contexts/AuthContext'; import { Skeleton, EmptyState, ArchetypeBadge, BookWordmark, TierRecord } from '@/components/vyndr'; @@ -284,6 +285,17 @@ function ModelHeader({ agg, minSample }: { agg: ModelAggregate; minSample: numbe {/* Wave 3 (Addition 2) β€” the ONE shared record-by-grade-tier table, identical here, on the dashboard, and on /u. */} + {/* Wave 5A (D2) β€” the shareable house/model profile. Same record, a + public page you can hand a partner. */} +
    + + VIEW AS PUBLIC PAGE β†’ + +
    ); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index cbe7130..15bc9b3 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import Hero from '@/components/Hero'; @@ -85,8 +86,16 @@ export default function Home() { {/* Session 58 (work-order 1.4) β€” the public model record (proof-strip footer). Deferred-render: "RECORD BUILDING" until 20 settles, then the real hit% + beat-close%. Self-hides with no data. */} -
    +
    + {/* Wave 5A (D2) β€” the shareable public model record (the house profile). */} + + VIEW PUBLIC RECORD β†’ +
    {/* Founder-seat scarcity meter (Β§12) */}
    diff --git a/web/src/app/u/[handle]/PublicProfile.tsx b/web/src/app/u/[handle]/PublicProfile.tsx index 8272ee6..8c9e3a9 100644 --- a/web/src/app/u/[handle]/PublicProfile.tsx +++ b/web/src/app/u/[handle]/PublicProfile.tsx @@ -61,6 +61,11 @@ interface ProfilePayload { aggregate: ProfileAggregate | null; entries: ProfileRow[]; min_sample?: number; + // Wave 5A (D2) β€” the HOUSE/model profile: the public user_id=NULL record, + // shareable without a claimed public_profiles row. `label` distinguishes it + // from a user profile ("VYNDR MODEL Β· PUBLIC RECORD"). + house?: boolean; + label?: string; } const SPORT_COLOR: Record = { @@ -115,18 +120,21 @@ export default function PublicProfile({ handle }: { handle: string }) { const agg = data.aggregate; const minSample = Number(data.min_sample) > 0 ? Number(data.min_sample) : 20; + const isHouse = Boolean(data.house); return (

    - PUBLIC LEDGER + {isHouse ? (data.label || 'VYNDR MODEL Β· PUBLIC RECORD') : 'PUBLIC LEDGER'}

    - @{data.handle} + {isHouse ? 'VYNDR MODEL' : `@${data.handle}`}

    - Every settled read. Wins and misses. Nothing curated. + {isHouse + ? 'The VYNDR model’s public record. Every graded read, wins and misses, closing-line value included.' + : 'Every settled read. Wins and misses. Nothing curated.'}

    diff --git a/web/src/app/u/[handle]/opengraph-image.tsx b/web/src/app/u/[handle]/opengraph-image.tsx index 8b63f46..b067632 100644 --- a/web/src/app/u/[handle]/opengraph-image.tsx +++ b/web/src/app/u/[handle]/opengraph-image.tsx @@ -11,7 +11,7 @@ export const contentType = 'image/png'; const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; -async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number } | null> { +async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; beat_close_pct: number | null; hits: number; misses: number; house: boolean } | null> { try { const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, { headers: { Accept: 'application/json' }, @@ -21,7 +21,7 @@ async function fetchRecord(handle: string): Promise<{ hit_pct: number | null; be const d = await r.json(); const agg = d && d.aggregate; if (!agg || agg.hit_pct == null) return null; - return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses }; + return { hit_pct: agg.hit_pct, beat_close_pct: agg.beat_close_pct ?? null, hits: agg.hits, misses: agg.misses, house: Boolean(d.house) }; } catch { return null; } @@ -49,10 +49,10 @@ export default async function Image({ params }: { params: Promise<{ handle: stri
    - {rec ? 'CLV-VERIFIED RECORD Β· 30D' : 'PUBLIC LEDGER'} + {rec ? (rec.house ? 'VYNDR MODEL Β· PUBLIC RECORD' : 'CLV-VERIFIED RECORD Β· 30D') : 'PUBLIC LEDGER'}
    - @{h} + {rec?.house ? 'VYNDR MODEL' : `@${h}`}
    {rec ? (
    diff --git a/web/src/app/u/[handle]/portrait/route.tsx b/web/src/app/u/[handle]/portrait/route.tsx new file mode 100644 index 0000000..ded4d1b --- /dev/null +++ b/web/src/app/u/[handle]/portrait/route.tsx @@ -0,0 +1,117 @@ +import { ImageResponse } from 'next/og'; +import type { NextRequest } from 'next/server'; + +/** + * /u/[handle]/portrait (Wave 5A, D2) β€” the PORTRAIT share crop (1080Γ—1350, + * the 4:5 story/feed format from the design mockup). The square-crop OG + * (opengraph-image.tsx, 1200Γ—630) unfurls a link; this is the standalone + * image you drop into a story or a partner deck. + * + * It reads the SAME /api/profiles/:handle payload β€” so the house handle + * (`vyndr`) renders the real public model record, a published user handle + * renders theirs, and anything else falls back to the tagline. NEVER a + * fabricated number: no record past the n-gate β†’ the tagline, not a zero. + * + * Self-hosted standalone build β†’ Node runtime (NOT edge; Session-53 rule). + */ +export const dynamic = 'force-dynamic'; + +const SIZE = { width: 1080, height: 1350 }; +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +async function fetchRecord(handle: string): Promise<{ + hit_pct: number | null; + beat_close_pct: number | null; + hits: number; + misses: number; + house: boolean; +} | null> { + try { + const r = await fetch(`${BACKEND_URL}/api/profiles/${encodeURIComponent(handle)}`, { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }); + if (!r.ok) return null; + const d = await r.json(); + const agg = d && d.aggregate; + if (!agg || agg.hit_pct == null) return null; + return { + hit_pct: agg.hit_pct, + beat_close_pct: agg.beat_close_pct ?? null, + hits: agg.hits, + misses: agg.misses, + house: Boolean(d.house), + }; + } catch { + return null; + } +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ handle: string }> }, +) { + const { handle } = await params; + const h = decodeURIComponent(handle || '').slice(0, 20).toLowerCase() || 'handle'; + const rec = await fetchRecord(h); + const heading = rec?.house ? 'VYNDR MODEL' : `@${h}`; + const eyebrow = rec ? (rec.house ? 'VYNDR MODEL Β· PUBLIC RECORD' : 'CLV-VERIFIED RECORD Β· 30D') : 'PUBLIC LEDGER'; + + return new ImageResponse( + ( +
    +
    +
    +
    + {eyebrow} +
    +
    + {heading} +
    +
    + + {rec ? ( +
    +
    +
    {rec.hit_pct}%
    +
    HIT RATE Β· {rec.hits}-{rec.misses}
    +
    + {rec.beat_close_pct != null && ( +
    +
    {rec.beat_close_pct}%
    +
    BEAT CLOSE
    +
    + )} +
    + ) : ( +
    + CLV-verified record Β· every settled read Β· misses included +
    + )} + +
    +
    + VYND + R +
    +
    + NOTHING CURATED Β· NOTHING DELETED +
    +
    +
    + ), + { ...SIZE }, + ); +} From 11fc5a66d2002038f8b0334630c38f792f364652 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 16:02:16 -0400 Subject: [PATCH 12/15] Wave 5B: Pitcher Arsenal via Baseball Savant (Statcast) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D4 β€” build the FREE Baseball Savant adapter for pitch-level identity (mix / velo / usage% / whiff%), the missing layer statsapi doesn't carry. - savantAdapter.getPitcherArsenal(id|name) β€” normalizes two public Savant CSV leaderboards (csv=true, NO parsing dependency): pitch-arsenal-stats (usage% + whiff% + K%) + pitch-arsenals avg_speed (velo). League-wide, cached 24h + in-memory mirror, indexed by MLBAM id. Defensive: null on any unrecognized shape; a missing velo/whiff is ABSENT (null), never 0. Injectable (fetchImpl/statsCsv/veloCsv/resolveId) β†’ tests hit no network. Live endpoints VERIFIED (200, exact columns) from the sandbox. - GET /api/stats/pitcher/:name/arsenal (stats.js) + Next proxy. MLB-only; an error/miss returns { found:false } so the card self-hides honestly. - PitcherArsenal.tsx (+ barrel) β€” the mockup's PITCHER IDENTITY strip: pitch mix % + velo + whiff%, mono/tabular, ranked by usage, sharpest-whiff pitch highlighted green. Self-hides (heading included) when arsenal absent. Mounted on the MLB player profile (a pitcher surface). Context, not a graded market value. - Tests: savantAdapter (fake CSV β†’ ranked arsenal; unknown shape/blank cells β†’ absent not 0; nameβ†’id resolve) + PitcherArsenal source locks (self-hide, mono/tabular, em-dash-not-zero). +2 suites / +17 tests (3012 β†’ 3029). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/routes/stats.js | 23 +- src/services/adapters/savantAdapter.js | 277 ++++++++++++++++++ tests/unit/pitcherArsenalComponent.test.js | 67 +++++ tests/unit/savantAdapter.test.js | 100 +++++++ .../api/stats/pitcher/[name]/arsenal/route.ts | 27 ++ web/src/app/globals.css | 3 + web/src/app/player/[name]/page.tsx | 8 + web/src/components/vyndr/PitcherArsenal.tsx | 175 +++++++++++ web/src/components/vyndr/index.ts | 2 + 9 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 src/services/adapters/savantAdapter.js create mode 100644 tests/unit/pitcherArsenalComponent.test.js create mode 100644 tests/unit/savantAdapter.test.js create mode 100644 web/src/app/api/stats/pitcher/[name]/arsenal/route.ts create mode 100644 web/src/components/vyndr/PitcherArsenal.tsx diff --git a/src/routes/stats.js b/src/routes/stats.js index e44fbe5..5af2525 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -2,7 +2,8 @@ const express = require('express'); const { getSupabaseServiceClient } = require('../utils/supabase'); const { getStatFilters } = require('../config/statFilters'); const { createRateLimit } = require('../middleware/rateLimit'); -const { getPlayerIntel, getLeaders } = require('../services/playerIntelService'); +const { getPlayerIntel, getLeaders, sanitizePlayerName } = require('../services/playerIntelService'); +const { getPitcherArsenal } = require('../services/adapters/savantAdapter'); const depthChart = require('../services/depthChartService'); const router = express.Router(); @@ -139,6 +140,26 @@ router.get('/player/:name', intelLimit, async (req, res) => { } }); +// GET /pitcher/:name/arsenal?sport=mlb β€” Baseball Savant pitch arsenal (Wave 5B). +// Pitch mix % + velo + whiff% (the mockup's PITCHER IDENTITY lens). FREE Statcast +// source; the adapter resolves nameβ†’MLBAM id + caches 24h. MLB only β€” any other +// sport (or a miss) returns { found:false } and the card self-hides. Never fabricates. +router.get('/pitcher/:name/arsenal', intelLimit, async (req, res) => { + try { + const sport = String(req.query.sport || 'mlb').toLowerCase(); + if (sport !== 'mlb') { + return res.set(MISSION_HEADER).json({ found: false, reason: 'arsenal is MLB-only' }); + } + const name = sanitizePlayerName(req.params.name); + const arsenal = await getPitcherArsenal(name); + res.set(MISSION_HEADER).json(arsenal || { found: false }); + } catch (err) { + console.error('[stats/pitcher/arsenal]', err.message); + // Honesty: an error is an ABSENT arsenal, not a fabricated one. Card self-hides. + res.set(MISSION_HEADER).json({ found: false }); + } +}); + // GET /leaders?sport=mlb&stat=hits&limit=10 β€” tonight's stat leaders (top // graded props by confidence) for the Terminal / Stats Explorer. router.get('/leaders', intelLimit, async (req, res) => { diff --git a/src/services/adapters/savantAdapter.js b/src/services/adapters/savantAdapter.js new file mode 100644 index 0000000..f464c7e --- /dev/null +++ b/src/services/adapters/savantAdapter.js @@ -0,0 +1,277 @@ +'use strict'; + +/** + * Baseball Savant / Statcast adapter (Wave 5B β€” Pitcher Arsenal). + * + * Pitch-level identity (mix / velo / usage% / whiff%) is NOT in statsapi.mlb.com β€” + * it lives on Baseball Savant (baseballsavant.mlb.com), which is FREE + public. + * `mlbStatsAdapter` already gives probable pitchers + ERA + game logs; the arsenal + * is the missing "one identity β†’ many props" lens (the mockup's PITCHER IDENTITY). + * + * ZERO-OUT-OF-POCKET + HONESTY doctrine (same as espnStatsAdapter): + * - Free source β†’ NO gateway / NO quota tracking. + * - Prefer a CSV/JSON leaderboard endpoint so NO new parsing dependency is + * needed (a tiny quote-aware CSV parser lives here; no cheerio/HTML scrape). + * - Defensive parsing: `null` on any unrecognized shape. A missing velo/whiff + * is ABSENT (null), NEVER 0 β€” `Number(null) === 0` is the fabrication trap. + * - Never fabricate: no pitcher β†’ `{ found:false }`; the card self-hides. + * - Injectable (`opts.fetchImpl` / `opts.statsCsv` / `opts.veloCsv` / + * `opts.resolveId` / cacheGet/cacheSet) β†’ tests never hit the network. + * + * SOURCE ENDPOINTS (public, `csv=true` β†’ no dependency): + * 1. Pitch-arsenal-stats (usage% + whiff% + K%) β€” LONG format, one row per + * (pitcher, pitch_type): + * https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year={year}&min=1&csv=true + * 2. Pitch-arsenals avg velo (best-effort enrichment) β€” WIDE format, one row + * per pitcher with a `{abbr}_avg_speed` column per pitch: + * https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year={year}&min=1&type=avg_speed&hand=&csv=true + * Both are LEAGUE-WIDE β†’ fetched once, cached 24h, indexed by MLBAM pitcher id. + * Velo is OPTIONAL: if endpoint #2's shape drifts, velo stays absent (null) β€” + * never a fabricated 0. NEEDS PROD VERIFICATION of the live column names. + */ + +const axios = require('axios'); +const { cacheGet: redisGet, cacheSet: redisSet } = require('../../utils/redis'); + +const HTTP_TIMEOUT_MS = 12_000; +const ARSENAL_TTL = 24 * 3600; // 24h β€” pitch mix is a slow-moving season identity +const LEAGUE_TTL = 24 * 3600; +const DEFAULT_SEASON = 2026; +const TOP_PITCHES = 6; // cap the strip β€” nobody throws more than ~6 real pitches + +// In-memory mirror so a single process serves the merged per-pitcher arsenal +// without a Redis round-trip (and so it degrades when Redis is down). +const _mem = new Map(); // key -> { value, exp } +function memGet(key) { + const hit = _mem.get(key); + if (!hit) return null; + if (hit.exp && hit.exp < Date.now()) { _mem.delete(key); return null; } + return hit.value; +} +function memSet(key, value, ttl) { + _mem.set(key, { value, exp: Date.now() + ttl * 1000 }); +} + +function statsUrl(year) { + return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenal-stats?type=pitcher&pitchType=&year=${year}&min=1&csv=true`; +} +function veloUrl(year) { + return `https://baseballsavant.mlb.com/leaderboard/pitch-arsenals?year=${year}&min=1&type=avg_speed&hand=&csv=true`; +} + +// ── Strict numeric parsing β€” absent beats zero ────────────────────────────── +// A blank / non-numeric field is null, NOT 0. Percentages arrive either as a +// whole number ("32.4") or a fraction ("0.324"); we normalize to a whole-number +// percent only when the source is clearly a fraction (0..1). +function numOrNull(raw) { + if (raw === null || raw === undefined) return null; + const s = String(raw).trim().replace('%', ''); + if (s === '' || s === 'NA' || s === 'null') return null; + const n = Number(s); + return Number.isFinite(n) ? n : null; +} +function pctOrNull(raw) { + const n = numOrNull(raw); + if (n === null) return null; + // Savant returns whole-number percents ("32.4"); a value in (0,1] is a + // fraction we scale up. Never invent a value where none exists. + return n > 0 && n <= 1 ? Math.round(n * 1000) / 10 : n; +} + +// ── Minimal quote-aware CSV parser (NO dependency) ────────────────────────── +// Savant's first column header is literally `"last_name, first_name"` (a quoted +// field containing a comma), so a naive split is wrong β€” we must honor quotes. +function parseCsvLine(line) { + const out = []; + let cur = ''; + let inQ = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQ) { + if (ch === '"') { + if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; + } else cur += ch; + } else if (ch === '"') { + inQ = true; + } else if (ch === ',') { + out.push(cur); cur = ''; + } else cur += ch; + } + out.push(cur); + return out; +} +function parseCsv(text) { + if (typeof text !== 'string' || text.trim() === '') return []; + const rows = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter((l) => l.length > 0); + if (rows.length < 2) return []; + const header = parseCsvLine(rows[0]).map((h) => h.trim()); + const objs = []; + for (let r = 1; r < rows.length; r++) { + const cols = parseCsvLine(rows[r]); + if (cols.length === 0) continue; + const obj = {}; + for (let c = 0; c < header.length; c++) obj[header[c]] = cols[c] !== undefined ? cols[c].trim() : ''; + objs.push(obj); + } + return objs; +} + +// A row must carry a pitcher id + a pitch_type to be usable. Unknown shape β†’ []. +function idOf(row) { + return row.player_id ?? row.pitcher ?? row.playerId ?? row.mlbam_id ?? null; +} + +// ── League-wide fetch + index (cached 24h, indexed by pitcher id) ─────────── +async function fetchText(url, opts) { + if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url); + const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS, responseType: 'text' }); + return typeof res.data === 'string' ? res.data : null; +} + +// stats leaderboard β†’ { [pitcherId]: [ { pitch_type, pitch_name, pitch_usage, whiff_percent, k_percent } ] } +async function loadStatsIndex(year, opts) { + const cacheGet = opts.cacheGet || redisGet; + const cacheSet = opts.cacheSet || redisSet; + const key = `savant:arsenal-stats:${year}`; + let csv = opts.statsCsv; + if (csv === undefined) { + const cached = await cacheGet(key).catch(() => null); + if (cached && typeof cached === 'object') return cached; // already indexed + csv = await fetchText(statsUrl(year), opts).catch(() => null); + } + const rows = parseCsv(csv); + if (rows.length === 0) return null; // unrecognized / empty β†’ caller decides + const index = {}; + let usable = 0; + for (const row of rows) { + const id = idOf(row); + const type = row.pitch_type != null ? String(row.pitch_type).trim() : ''; + if (id == null || String(id).trim() === '' || type === '') continue; + const key2 = String(id).trim(); + (index[key2] = index[key2] || []).push({ + type, + name: row.pitch_name ? String(row.pitch_name).trim() : type, + usagePct: pctOrNull(row.pitch_usage), + whiffPct: pctOrNull(row.whiff_percent), + kPct: pctOrNull(row.k_percent), + }); + usable++; + } + if (usable === 0) return null; // header present but no id/pitch columns β†’ unknown shape + if (opts.statsCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {}); + return index; +} + +// velo leaderboard (WIDE) β†’ { [pitcherId]: { ff: 99.1, sl: 87.0, ... } } (lowercased abbr β†’ velo) +async function loadVeloIndex(year, opts) { + const cacheGet = opts.cacheGet || redisGet; + const key = `savant:arsenal-velo:${year}`; + let csv = opts.veloCsv; + if (csv === undefined) { + const cached = await cacheGet(key).catch(() => null); + if (cached && typeof cached === 'object') return cached; + csv = await fetchText(veloUrl(year), opts).catch(() => null); + } + const rows = parseCsv(csv); + if (rows.length === 0) return {}; // velo is OPTIONAL β€” absent index is fine + const index = {}; + for (const row of rows) { + const id = idOf(row); + if (id == null || String(id).trim() === '') continue; + const speeds = {}; + for (const col of Object.keys(row)) { + const m = /^([a-z]{1,3})_avg_speed$/i.exec(col); + if (!m) continue; + const v = numOrNull(row[col]); + if (v !== null) speeds[m[1].toLowerCase()] = v; + } + if (Object.keys(speeds).length) index[String(id).trim()] = speeds; + } + if (opts.veloCsv === undefined) await (opts.cacheSet || redisSet)(key, index, LEAGUE_TTL).catch(() => {}); + return index; +} + +/** + * Resolve a pitcher's arsenal by MLBAM id (preferred) or name. + * @returns {Promise<{found:boolean, playerId?:(number|string), pitches?:Array, source?:string}>} + * pitches: [{ type, name, usagePct, velo|null, whiffPct|null, kPct|null }], ranked by usage desc. + * Always graceful β€” any miss / unrecognized shape β†’ { found:false }. + */ +async function getPitcherArsenal(idOrName, opts = {}) { + try { + const year = opts.year || DEFAULT_SEASON; + + // Resolve name β†’ MLBAM id when we weren't given a numeric id. + let id = idOrName; + const isNumericId = id != null && /^\d+$/.test(String(id).trim()); + if (!isNumericId) { + const name = String(idOrName || '').trim(); + if (!name) return { found: false }; + const resolveId = opts.resolveId || (async (n) => { + const mlb = opts.mlbAdapter || require('./mlbStatsAdapter'); + const person = await mlb.searchPlayer(n).catch(() => null); + return person && person.id != null ? person.id : null; + }); + id = await resolveId(name); + if (id == null) return { found: false }; + } + const key = String(id).trim(); + + // Merged per-pitcher cache (Redis + in-memory mirror), only for the live path + // (injected CSV fixtures skip the cache so tests are deterministic). + const usingFixtures = opts.statsCsv !== undefined || opts.veloCsv !== undefined; + const memKey = `savant:arsenal:${key}:${year}`; + if (!usingFixtures) { + const m = memGet(memKey); + if (m) return m; + const cached = await (opts.cacheGet || redisGet)(memKey).catch(() => null); + if (cached && typeof cached === 'object') { memSet(memKey, cached, ARSENAL_TTL); return cached; } + } + + const statsIndex = await loadStatsIndex(year, opts); + if (!statsIndex) return { found: false }; // unrecognized / empty stats feed + const rows = statsIndex[key]; + if (!Array.isArray(rows) || rows.length === 0) return { found: false }; + + // Velo is best-effort enrichment; a failure leaves velo absent (null). + // In fixture mode (statsCsv injected) with NO veloCsv, DON'T hit the network β€” + // tests stay hermetic and velo is honestly absent. + let veloIndex = {}; + if (opts.veloCsv !== undefined || !usingFixtures) { + try { veloIndex = await loadVeloIndex(year, opts); } catch { veloIndex = {}; } + } + const speeds = (veloIndex && veloIndex[key]) || {}; + + const pitches = rows + .map((p) => ({ + type: p.type, + name: p.name, + usagePct: p.usagePct, + velo: (speeds[p.type.toLowerCase()] !== undefined ? speeds[p.type.toLowerCase()] : null), + whiffPct: p.whiffPct, + kPct: p.kPct, + })) + // rank by usage desc; a null usage sorts last (never fabricated to 0) + .sort((a, b) => (b.usagePct ?? -1) - (a.usagePct ?? -1)) + .slice(0, TOP_PITCHES); + + const result = { found: true, playerId: /^\d+$/.test(key) ? Number(key) : key, pitches, source: 'baseball_savant' }; + if (!usingFixtures) { + memSet(memKey, result, ARSENAL_TTL); + await (opts.cacheSet || redisSet)(memKey, result, ARSENAL_TTL).catch(() => {}); + } + return result; + } catch (err) { + console.warn('[savant] getPitcherArsenal failed:', idOrName, err && err.message); + return { found: false }; + } +} + +module.exports = { + getPitcherArsenal, + __internals: { + parseCsv, parseCsvLine, numOrNull, pctOrNull, idOf, + loadStatsIndex, loadVeloIndex, statsUrl, veloUrl, + DEFAULT_SEASON, ARSENAL_TTL, TOP_PITCHES, _mem, + }, +}; diff --git a/tests/unit/pitcherArsenalComponent.test.js b/tests/unit/pitcherArsenalComponent.test.js new file mode 100644 index 0000000..d2e2607 --- /dev/null +++ b/tests/unit/pitcherArsenalComponent.test.js @@ -0,0 +1,67 @@ +'use strict'; + +// Wave 5B β€” PitcherArsenal component source locks. The card is CONTEXT (the +// arsenal read), never a graded market value. Honesty invariants: +// β€’ SELF-HIDES (returns null) when Savant has no arsenal β€” no empty shell. +// β€’ ALL pitch data (velo / usage / whiff) is mono + tabular (brand rule). +// β€’ A missing velo/whiff renders "β€”" (absent), NEVER 0. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +describe('PitcherArsenal component', () => { + const src = read('components/vyndr/PitcherArsenal.tsx'); + + test('self-hides (returns null) when there are no pitches', () => { + expect(src).toMatch(/if\s*\(\s*pitches\.length\s*===\s*0\s*\)\s*return null/); + }); + + test('renders the arsenal strip headers (VELO / USE / WHIFF)', () => { + expect(src).toContain('VELO'); + expect(src).toContain('USE'); + expect(src).toContain('WHIFF'); + expect(src).toContain('PITCH'); + }); + + test('pitch data is mono + tabular (brand rule: data is mono)', () => { + expect(src).toContain('className="mono"'); + expect(src).toContain('tabular-nums'); + // the three data cells map the normalized fields + expect(src).toContain('fmtVelo(p.velo)'); + expect(src).toContain('fmtPct(p.usagePct)'); + expect(src).toContain('fmtPct(p.whiffPct)'); + }); + + test('absent velo/whiff renders an em-dash, never 0 (absent beats zero)', () => { + // fmtVelo / fmtPct return 'β€”' for null|undefined + expect(src).toMatch(/function fmtVelo[\s\S]*?===\s*null[\s\S]*?['"]β€”['"]/); + expect(src).toMatch(/function fmtPct[\s\S]*?===\s*null[\s\S]*?['"]β€”['"]/); + // no `?? 0` / `|| 0` coercion on the numeric fields + expect(src).not.toMatch(/velo\s*(\?\?|\|\|)\s*0/); + expect(src).not.toMatch(/whiffPct\s*(\?\?|\|\|)\s*0/); + }); + + test('cites Baseball Savant as the source (honest provenance)', () => { + expect(src).toMatch(/BASEBALL SAVANT/i); + }); + + test('ranks the strip by the normalized fields from the adapter contract', () => { + // consumes { found, pitches:[{ type, name, usagePct, velo, whiffPct }] } + expect(src).toContain('fetched.found'); + expect(src).toContain('fetched.pitches'); + }); + + test('is exported from the vyndr barrel', () => { + const barrel = read('components/vyndr/index.ts'); + expect(barrel).toContain("export { default as PitcherArsenal }"); + }); + + test('the arsenal proxy route exists (S25 rule) and forwards to the backend', () => { + const proxy = read('app/api/stats/pitcher/[name]/arsenal/route.ts'); + expect(proxy).toContain('/api/stats/pitcher/'); + expect(proxy).toContain('/arsenal'); + expect(proxy).toContain('found: false'); // honest fallback, not an error card + }); +}); diff --git a/tests/unit/savantAdapter.test.js b/tests/unit/savantAdapter.test.js new file mode 100644 index 0000000..8e82613 --- /dev/null +++ b/tests/unit/savantAdapter.test.js @@ -0,0 +1,100 @@ +'use strict'; + +const savant = require('../../src/services/adapters/savantAdapter'); + +// Fixture CSVs shaped like the real Baseball Savant leaderboard exports. +// NOTE the first header column is a QUOTED field containing a comma +// (`"last_name, first_name"`) β€” the parser must be quote-aware. +const STATS_CSV = [ + '"last_name, first_name",player_id,team_name_alt,pitch_type,pitch_name,run_value_per_100,pitches,pitch_usage,pa,whiff_percent,k_percent', + '"Skenes, Paul",694973,PIT,FF,4-Seam Fastball,1.2,500,32.0,300,26.0,30.0', + '"Skenes, Paul",694973,PIT,SL,Slider,2.1,350,22.0,200,41.0,38.0', + '"Skenes, Paul",694973,PIT,FS,Splitter,1.8,300,24.0,180,38.0,",', // trailing malformed cell β†’ whiff null-ish; still parses + '"Skenes, Paul",694973,PIT,CU,Curveball,0.5,120,14.0,90,33.0,20.0', + '"Other, Guy",111111,LAD,CH,Changeup,0.1,50,,40,,', // usage/whiff BLANK β†’ must be null, never 0 +].join('\n'); + +// WIDE velo leaderboard β€” one row per pitcher, `{abbr}_avg_speed` columns. +const VELO_CSV = [ + '"last_name, first_name",pitcher,team,ff_avg_speed,sl_avg_speed,fs_avg_speed,cu_avg_speed', + '"Skenes, Paul",694973,PIT,99.1,87.0,94.2,82.5', +].join('\n'); + +describe('savantAdapter.getPitcherArsenal', () => { + test('normalizes a fake Savant CSV payload into a ranked arsenal (usage desc)', async () => { + const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV, veloCsv: VELO_CSV }); + expect(out.found).toBe(true); + expect(out.playerId).toBe(694973); + expect(out.source).toBe('baseball_savant'); + expect(Array.isArray(out.pitches)).toBe(true); + // ranked by usage: FF(32) > FS(24) > SL(22) > CU(14) + expect(out.pitches.map((p) => p.type)).toEqual(['FF', 'FS', 'SL', 'CU']); + const ff = out.pitches[0]; + expect(ff.usagePct).toBe(32.0); + expect(ff.velo).toBe(99.1); // merged from the WIDE velo CSV by pitch abbr + expect(ff.whiffPct).toBe(26.0); + const sl = out.pitches.find((p) => p.type === 'SL'); + expect(sl.velo).toBe(87.0); + expect(sl.whiffPct).toBe(41.0); + }); + + test('missing velo is ABSENT (null), never 0 β€” velo CSV omitted entirely', async () => { + const out = await savant.getPitcherArsenal(694973, { statsCsv: STATS_CSV /* no veloCsv */ }); + expect(out.found).toBe(true); + for (const p of out.pitches) expect(p.velo).toBeNull(); + }); + + test('blank whiff/usage cells parse to null, not 0 (absent beats zero)', async () => { + const out = await savant.getPitcherArsenal(111111, { statsCsv: STATS_CSV }); + expect(out.found).toBe(true); + expect(out.pitches).toHaveLength(1); + expect(out.pitches[0].usagePct).toBeNull(); + expect(out.pitches[0].whiffPct).toBeNull(); + expect(out.pitches[0].velo).toBeNull(); + }); + + test('unrecognized shape β†’ { found:false } (defensive parsing)', async () => { + const junk = 'totally,unrelated,columns\n1,2,3'; + const out = await savant.getPitcherArsenal(694973, { statsCsv: junk }); + expect(out.found).toBe(false); + const empty = await savant.getPitcherArsenal(694973, { statsCsv: '' }); + expect(empty.found).toBe(false); + }); + + test('a pitcher with no rows in the feed β†’ { found:false }', async () => { + const out = await savant.getPitcherArsenal(999999, { statsCsv: STATS_CSV }); + expect(out.found).toBe(false); + }); + + test('resolves a NAME β†’ id via injected resolver, then returns arsenal', async () => { + const out = await savant.getPitcherArsenal('Paul Skenes', { + statsCsv: STATS_CSV, + veloCsv: VELO_CSV, + resolveId: async (n) => (/skenes/i.test(n) ? 694973 : null), + }); + expect(out.found).toBe(true); + expect(out.playerId).toBe(694973); + }); + + test('unresolvable name / empty input β†’ { found:false } (never throws)', async () => { + const noId = await savant.getPitcherArsenal('Nobody Here', { statsCsv: STATS_CSV, resolveId: async () => null }); + expect(noId.found).toBe(false); + const blank = await savant.getPitcherArsenal('', { statsCsv: STATS_CSV }); + expect(blank.found).toBe(false); + }); + + test('quote-aware CSV parser keeps a comma inside a quoted field', () => { + const rows = savant.__internals.parseCsv('"last, first",id\n"Skenes, Paul",694973'); + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe('694973'); + expect(rows[0]['last, first']).toBe('Skenes, Paul'); + }); + + test('pctOrNull scales a fraction but leaves whole percents alone; null stays null', () => { + const { pctOrNull } = savant.__internals; + expect(pctOrNull('0.324')).toBe(32.4); + expect(pctOrNull('32.4')).toBe(32.4); + expect(pctOrNull('')).toBeNull(); + expect(pctOrNull(null)).toBeNull(); + }); +}); diff --git a/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts b/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts new file mode 100644 index 0000000..54c9c25 --- /dev/null +++ b/web/src/app/api/stats/pitcher/[name]/arsenal/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** + * Pitcher arsenal proxy (Wave 5B). Forwards GET + * /api/stats/pitcher/:name/arsenal to the Express stats route (Baseball Savant + * pitch mix / velo / whiff). Thin pass-through; preserves ?sport=. On any + * upstream failure it returns { found:false } so the PitcherArsenal card + * self-hides honestly rather than showing an error. + */ +export async function GET(req: NextRequest, ctx: { params: Promise<{ name: string }> }) { + const { name } = await ctx.params; + const qs = req.nextUrl.search; + try { + const upstream = await fetch( + `${BACKEND_URL}/api/stats/pitcher/${encodeURIComponent(name)}/arsenal${qs}`, + { method: 'GET', headers: { Accept: 'application/json' } }, + ); + const data = await upstream.json().catch(() => ({ found: false })); + return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status }); + } catch { + return NextResponse.json({ found: false }, { status: 200 }); + } +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index aa810e9..9c73df8 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1333,6 +1333,9 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; } /* Terminal's multi-column grid stacks on mobile. */ @media (max-width: 768px) { .terminal-grid { grid-template-columns: 1fr !important; } + /* Pitcher-identity (Wave 5B): identity + arsenal columns stack; the arsenal + table keeps its own internal grid. */ + .parsenal-grid { grid-template-columns: 1fr !important; } } /* ── Session 59 (work-order 3.2) β€” overflow containment at 390px ──────── */ diff --git a/web/src/app/player/[name]/page.tsx b/web/src/app/player/[name]/page.tsx index 6db4cb6..9ebffb6 100644 --- a/web/src/app/player/[name]/page.tsx +++ b/web/src/app/player/[name]/page.tsx @@ -8,6 +8,7 @@ import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge'; import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend'; import ModelRecord from '@/components/vyndr/ModelRecord'; import PlayerStreaks from '@/components/vyndr/PlayerStreaks'; +import PitcherArsenal from '@/components/vyndr/PitcherArsenal'; import { archetypeInfo } from '@/lib/archetypes'; import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter'; @@ -201,6 +202,13 @@ export default function PlayerProfilePage() {
    )} + {/* C2. PITCHER IDENTITY (Wave 5B) β€” Baseball Savant arsenal lens. MLB only; + the component (heading included) SELF-HIDES for non-pitchers / when + Savant has no arsenal. Context, not a graded market value. */} + {p.sport === 'mlb' && ( + + )} + {/* D. ACTIVE PROPS */} {p.activeProps?.length > 0 && ( <> diff --git a/web/src/components/vyndr/PitcherArsenal.tsx b/web/src/components/vyndr/PitcherArsenal.tsx new file mode 100644 index 0000000..df61036 --- /dev/null +++ b/web/src/components/vyndr/PitcherArsenal.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * PitcherArsenal (Wave 5B) β€” the mockup's PITCHER IDENTITY lens: pitch mix % + + * velo + whiff%, mono/tabular, ranked by usage. Fed by the Baseball Savant + * arsenal adapter (FREE Statcast). "ONE IDENTITY β†’ MANY PROPS". + * + * HONESTY: arsenal is CONTEXT (the read), never a graded market value. The card + * SELF-HIDES (returns null) when Savant has no arsenal for the pitcher β€” no empty + * box, no fabricated numbers. A missing velo/whiff renders as "β€”" (absent), NOT 0. + * + * Two modes: + * β€’ pass `arsenal` (already fetched by a parent) β€” renders synchronously. + * β€’ pass `name` (+ optional pitcher meta) β€” fetches /api/stats/pitcher/:name/arsenal. + */ + +export interface ArsenalPitch { + type: string; // FF / SL / FS / CU … + name: string; // "4-Seam Fastball" + usagePct: number | null; + velo: number | null; + whiffPct: number | null; + kPct?: number | null; +} +export interface PitcherArsenalData { + found: boolean; + playerId?: number | string; + pitches?: ArsenalPitch[]; +} +export interface PitcherMeta { + name?: string; + hand?: string; // RHP / LHP + number?: string | number; // jersey + team?: string; + vs?: string; // opponent abbr + confirmed?: boolean; // probable-pitcher confirmed +} +interface Props { + arsenal?: PitcherArsenalData | null; + name?: string; // fetch by name when arsenal not supplied + sport?: string; // default 'mlb' + pitcher?: PitcherMeta; // optional identity header + heading?: boolean; // render the "PITCHER IDENTITY" label above the card +} + +// Pitch color dots (Statcast-flavored, matching the mockup). Data chrome β€” no glitch. +const PITCH_COLOR: Record = { + FF: '#FF7A5A', FA: '#FF7A5A', // 4-seam / fastball + SI: '#E0803D', FT: '#E0803D', FS: '#E0803D', FO: '#E0803D', // sinker / two-seam / splitter + FC: '#E8A33D', // cutter + SL: '#7C5CFF', ST: '#7C5CFF', SV: '#9B7CFF', // slider / sweeper / slurve + CU: '#6C9CB0', KC: '#6C9CB0', CS: '#6C9CB0', // curveballs + CH: '#4FB0A0', SC: '#4FB0A0', // change / screw + KN: '#8888A0', EP: '#8888A0', // knuckle / eephus +}; +function pitchColor(type: string) { + return PITCH_COLOR[String(type || '').toUpperCase()] || '#6C9CB0'; +} + +// Absent beats zero β€” a null velo/whiff/usage renders as an em-dash, never 0. +function fmtVelo(v: number | null | undefined) { + return v === null || v === undefined ? 'β€”' : v.toFixed(1); +} +function fmtPct(v: number | null | undefined) { + return v === null || v === undefined ? 'β€”' : `${Math.round(v)}%`; +} + +const COL = '1fr 56px 46px 52px'; + +export default function PitcherArsenal({ arsenal, name, sport = 'mlb', pitcher, heading }: Props) { + const [fetched, setFetched] = useState(arsenal ?? null); + + useEffect(() => { + if (arsenal !== undefined && arsenal !== null) { setFetched(arsenal); return; } + if (!name) return; + let alive = true; + fetch(`/api/stats/pitcher/${encodeURIComponent(name)}/arsenal?sport=${encodeURIComponent(sport)}`, { + headers: { Accept: 'application/json' }, + }) + .then((r) => r.json()) + .then((d) => { if (alive) setFetched(d && typeof d === 'object' ? d : null); }) + .catch(() => { if (alive) setFetched(null); }); + return () => { alive = false; }; + }, [arsenal, name, sport]); + + const pitches = fetched && fetched.found && Array.isArray(fetched.pitches) ? fetched.pitches : []; + // SELF-HIDE: no real arsenal β†’ render nothing (never an empty shell). + if (pitches.length === 0) return null; + + // Highlight the sharpest whiff pitch(es) green β€” the "what misses bats" read. + const maxWhiff = Math.max(...pitches.map((p) => (p.whiffPct ?? -1))); + const isSharp = (p: ArsenalPitch) => p.whiffPct !== null && p.whiffPct !== undefined && p.whiffPct >= 30 && p.whiffPct >= maxWhiff - 3; + + const meta = pitcher || {}; + const monogram = (meta.team || (meta.name || '').slice(0, 3) || 'PIT').toString().slice(0, 3).toUpperCase(); + + const Card = ( +
    +
    + {/* identity */} +
    +
    +
    {monogram}
    +
    +
    + {meta.name || 'Probable Pitcher'} + {meta.confirmed && ( + CONFIRMED + )} +
    +
    + {[meta.hand, meta.number != null ? `#${meta.number}` : null, meta.team, meta.vs ? `vs ${meta.vs}` : null].filter(Boolean).join(' Β· ') || 'ARSENAL'} +
    +
    +
    +
    +
    THE ARSENAL READ
    +

    + {(() => { + const top = pitches[0]; + const sharp = pitches.find(isSharp); + if (sharp && top && sharp.type !== top.type) { + return `${top.name} sets it up; the ${sharp.name.toLowerCase()} is the swing-and-miss pitch (${fmtPct(sharp.whiffPct)} whiff).`; + } + if (top) return `${top.name}-led mix β€” ${fmtPct(top.usagePct)} usage. One identity, many props.`; + return 'Pitch mix read. One identity, many props.'; + })()} +

    +
    +
    + + {/* arsenal table */} +
    +
    + PITCH + VELO + USE + WHIFF +
    + {pitches.map((p, i) => { + const sharp = isSharp(p); + return ( +
    + + + {p.name} + + {fmtVelo(p.velo)} + {fmtPct(p.usagePct)} + {fmtPct(p.whiffPct)} +
    + ); + })} +
    SOURCE Β· BASEBALL SAVANT (STATCAST)
    +
    +
    +
    + ); + + if (!heading) return Card; + // Heading lives INSIDE the self-hide guard (past the early `return null`), so an + // absent arsenal drops the label too β€” never an orphan header. + return ( +
    +
    + + PITCHER IDENTITY + ONE IDENTITY β†’ MANY PROPS +
    + {Card} +
    + ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index 1dd53a3..f5975c2 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -27,6 +27,8 @@ export { default as ArchetypeBlend } from './ArchetypeBlend'; export type { BlendSegment } from './ArchetypeBlend'; export { default as StatStrip } from './StatStrip'; export type { StatCell, StripProp, StripArchetype } from './StatStrip'; +export { default as PitcherArsenal } from './PitcherArsenal'; +export type { PitcherArsenalData, ArsenalPitch, PitcherMeta } from './PitcherArsenal'; export { default as BookChip } from './BookChip'; /* DS0 (Design v2) β€” the Entity Layer: teams/players/books as themselves. */ From 016758e0142c1ebc167a0e13688f2d01d73ce23e Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 16:07:33 -0400 Subject: [PATCH 13/15] Combat intelligence spec (Wave 6): honest free v1, pinned archetype registry Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/combat-intelligence.md | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 specs/combat-intelligence.md diff --git a/specs/combat-intelligence.md b/specs/combat-intelligence.md new file mode 100644 index 0000000..05e9c7e --- /dev/null +++ b/specs/combat-intelligence.md @@ -0,0 +1,55 @@ +# VYNDR β€” COMBAT INTELLIGENCE LAYER (spec) +### Wave 6 of the wiring/data train. Net-new sport (MMA/UFC). Governs the combat build. Honest free v1; the full matchup-GRADE engine is a DEFERRED sub-wave. Build toward `specs/design-reference/vyndr-system.html` (FIGHTER A / VERDICT / FIGHTER B, GRAPPLER%/STRIKER% blend, tale-of-the-tape, MONEYLINE/DECISION/SUBMISSION/KO grades). Keep the live wordmark. + +## DATA SEMANTICS (unchanged, restated for combat) +VYNDR never generates odds β€” ML/round-total values are REAL book numbers at a timestamp. Fighter records/physicals/style stats are REAL sourced facts. Model output (style edge, any grade) is always labeled MODEL. `Number(null)===0` is the trap β€” absent stat β‡’ absent, never 0. **Never ingest a fighter's photo/likeness** (same rule as headshots) β€” initials monogram only. Scraping fragility β‡’ degrade to absent, never fabricate. + +## SCOPE β€” v1 (this wave) vs DEFERRED +**v1 ships:** fight-card discovery + tale-of-the-tape + style-blend archetypes + ML & round-total odds + a **style-edge VERDICT (a MODEL style read, explicitly NOT a settled grade)**. +**DEFERRED (own sub-wave, do NOT build now):** the matchup-GRADE engine that produces settled ML/method/round grades; method-of-victory & round & fighter props (data-limited on the free feed); combat outcome SETTLEMENT (no free box-score settle path yet β€” grades would stay `pending`); ufcstats.com scraping (needs a parser dep β€” CONFIRM before adding). + +## DATA SOURCES (zero-out-of-pocket) +- **ESPN MMA (FREE, JSON, no auth β€” same family VYNDR already uses):** + - Fight cards / schedule: `site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard` (date-pinned like the other sports). + - Event / fight detail + results + tale-of-the-tape: `.../mma/ufc/summary?event={id}` and the athlete endpoints (`athlete.id`, record, stance, reach, weight class). ESPN athlete id also gives a headshot via the entity layer's `a.espncdn.com/i/headshots/mma/...` pattern β€” but per the likeness rule, v1 uses initials monograms; wire the id but default to monogram. + - Depth caveat (honest): ESPN MMA striking/grappling granularity is THINNER than ufcstats. Style-blend is best-effort from what ESPN exposes (finish history, method-of-victory counts, takedown/strike splits where present); when a stat is absent, the blend says less β€” never invents. +- **The Odds API `mma_mixed_martial_arts` (ALREADY PAID β€” `ODDS_API_KEY`):** wire `oddsService.SPORT_KEYS.mma = 'mma_mixed_martial_arts'` + `MMA_MARKETS = ['h2h','totals']` (moneyline + round totals). Reuse `oddsNormalizer`. **PropLine has NO combat** β†’ odds-api-only; combat does NOT enter the abundant-props path. + +## COMBAT ARCHETYPE REGISTRY (PINNED β€” both `src/services/archetypeService.js` AND `web/src/lib/archetypes.js` use these EXACT names/colors/glyphs; a test asserts they match, same as other sports). Colors unique WITHIN combat; may reuse hues used in other sports (cross-sport reuse is fine). +Six styles. `classify('mma', fighterStats)` returns `{ primary, secondary|null, blend:[{archetype,weight}] }` (same contract as other sports) scored from finish-rate / method splits / takedown & strike tendencies. + +| Name | Glyph | Color | One-liner (shown where it leads) | +|---|---|---|---| +| STRIKER | ✦ | `#E8703A` | Wins on the feet β€” volume + power at range. | +| GRAPPLER | βŠ— | `#2FA4E7` | Fight hits the mat on his terms β€” control + subs. | +| PRESSURE | ➀ | `#E4574C` | Forward, relentless, breaks the pace. | +| COUNTER | β—Š | `#8E7BE0` | Patient β€” punishes what you show him. | +| FINISHER | β–² | `#12B886` | Ends nights β€” high KO/SUB rate. | +| GRINDER | β–¦ | `#B0883B` | Goes the distance, wins the rounds. | +STRIKER↔GRAPPLER is the primary range axis the mockup renders as the two blend bars; PRESSURE/COUNTER is tempo; FINISHER/GRINDER is the outcome tendency. A fighter is a BLEND (e.g. GRAPPLER 80% / STRIKER 30%). Discipline pedigree tags (Combat Sambo, Dagestan Wrestling, BJJ, Wrestling Base, Kickboxing, Muay Thai, Boxing) are **verifiable credentials**, rendered separately from the archetype blend β€” VERIFIABLE ONLY, mark unknowns absent, never guess a fighter's base. + +## STYLE-MATCHUP VERDICT (v1 β€” a MODEL read, not a grade) +`styleMatchup(fighterA, fighterB)` β†’ a style-edge verdict (e.g. "GRAPPLER EDGE Β· Islam") from comparing the two blends + finish/defense tendencies. This is a **descriptive MODEL read**, labeled as such β€” NOT a settled grade, NOT an edge %, NO fabricated confidence. The mockup's CENTER VERDICT. Keep it honest: if the data is too thin to call, say "STYLES EVEN / INSUFFICIENT READ". + +## CONFIG WIRING (the recurring 4-layer-desync trap β€” do all in sync) +- `src/config/sports.js` `SPORT_CONFIG` + legacy `SPORTS` (flip `mma.active`), `web/src/config/sports.ts` mirror. +- `oddsService.SPORT_KEYS`/`SPORT_MARKETS` + `oddsNormalizer` MMA market keys. +- `src/config/statFilters.js` + `web/src/config/statFilters.ts` β€” combat stat categories (if any props surface). +- Combat is NOT added to `snapshotService.ACTIVE_SPORTS` in v1 (no settle path) β€” fight cards + odds + style card render on-read; grades stay OUT of the locked-snapshot loop until the deferred engine. + +## FRONTEND (toward the mockup) +- **Tale-of-the-tape / head-to-head style card** β€” `web/src/components/vyndr/FightCard.tsx`: FIGHTER A (initials monogram, name, record e.g. 26–1, stance, reach) Β· GRAPPLER%/STRIKER% blend bars Β· discipline pedigree tags Β· archetype chip(s) via the shared `ArchetypeBadge` Β· CENTER VERDICT (style edge) Β· FIGHTER B mirror Β· a grades/odds row (MONEYLINE + round total from the odds feed; method/KO/SUB shown as "β€” data-limited" honest placeholders, NOT fabricated). Two fighters side-by-side (NOT the player-strip row grammar). Mono data, no glitch on data. +- **MMA badge/color** β€” the `#D4AF37` combat token already exists in `shareCards/tokens.js`; add a frontend MMA sport token + `SportBadge` support. +- **Route** β€” `web/src/app/fight/[id]/page.tsx` (server wrapper + client card) and/or a combat surface on the schedule; add Next proxies for the ESPN-MMA read (S25 rule). Self-hide / honest empty (reuse `EmptyState`) off-season. +- Sportsbook links via the existing `bookLinks.js`/affiliate layer unchanged. + +## TESTS +- `archetypeService`/`archetypes.js` combat colors MATCH (extend the existing cross-file color test). +- `classify('mma', …)` blends correctly from fixtures; thin data β†’ fewer/absent style claims (never fabricated). +- `styleMatchup` β†’ honest "insufficient read" on thin data; a clear edge on divergent styles. +- combat adapter: defensive parse (ESPN shape β†’ normalized fight card; unrecognized β†’ empty, never throw); injectable, NO network. +- `oddsNormalizer` MMA market keys map (h2h/totals), else combat odds silently zero. +- FightCard self-hides / honest-empty; renders tale-of-the-tape mono; method/round shown as data-limited, not fabricated. + +## OUT OF SCOPE REMINDER +No settled grades, no method/round/props board, no fighter photos, no scraping dep β€” all DEFERRED and flagged in-UI as data-limited rather than promised. The cheapest honest v1 is the goal: styles + tape + ML/round-totals, beautifully rendered. From 54fa5853f5cf11c45b0bf4a7c331f86cff0aa0f9 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 16:58:22 -0400 Subject: [PATCH 14/15] Wave 6: Combat Intelligence Layer (honest free v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new MMA/UFC vertical β€” fight-card discovery, tale-of-the-tape, style-blend archetypes, ML + round-total odds, and a style-edge VERDICT (a MODEL read, explicitly NOT a settled grade). Built to specs/combat-intelligence.md. Backend: - combatAdapter: ESPN MMA scoreboard (date-pinned, free JSON) -> fight cards + tale-of-tape (record/weight class/rounds/ESPN athlete id); defensive parse (null on unknown shape, never throws); injectable fetchImpl + cache; pure normalizeCombatOdds (odds-api h2h/totals -> ML + round total, allow-listed books, best price). Number(null) guard. - archetypeService: 6 pinned combat styles in a SEPARATE COMBAT_ARCHETYPES registry (FINISHER collides with soccer + its green trips the signal- green gate); classify('mma') blends range/tempo/outcome, honest-empty on thin data (no forced fallback); styleMatchup() honest verdict. - oddsService: SPORT_KEYS.mma + MMA_MARKETS=['h2h','totals'] + SPORT_MARKETS (no spreads suffix). oddsNormalizer MARKET_MAP h2h/totals. - config/sports.js + web mirror: mma.active=true (collectData stays false; NOT in the graded-props pipeline SPORT_CONFIG or snapshot/settle loop). - routes/combat.js: GET /api/combat/:date + GET /api/fight/:id (public, cached, honest empty off-card) + Next proxies. Frontend: - FightCard: two-fighter tale-of-the-tape (initials monogram β€” no photos), GRAPPLER/STRIKER blend bars, discipline pedigree tags, shared ArchetypeBadge (sport="mma", unicode glyphs), CENTER VERDICT, ML + round-total real; method/round/KO = honest "data-limited", never fabricated. Self-hides on a non-two-fighter bout. - /fight/[id] page (server wrapper + client), EmptyState off-season. - MMA SportBadge token (#D4AF37); archetypes.js sport-aware resolution. DEFERRED (per spec, NOT built): matchup-GRADE engine, method/round/props board, combat settlement, ufcstats scraping. Tests: +3 suites (31 tests) β€” combat archetype cross-file color/glyph match, classify blends, styleMatchup honesty, adapter defensive parse + odds normalize, FightCard honesty grep; extended oddsNormalizer + sportMarkets. Full suite 253/253 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 6 + src/config/sports.js | 6 +- src/routes/combat.js | 81 +++++ src/services/adapters/combatAdapter.js | 358 ++++++++++++++++++++ src/services/archetypeService.js | 185 +++++++++- src/services/oddsService.js | 13 + src/utils/oddsNormalizer.js | 10 + tests/unit/combatAdapter.test.js | 185 ++++++++++ tests/unit/combatArchetypes.test.js | 108 ++++++ tests/unit/combatFightCard.test.js | 50 +++ tests/unit/oddsNormalizer.test.js | 10 + tests/unit/sportMarkets.test.js | 13 +- web/src/app/api/combat/[date]/route.ts | 27 ++ web/src/app/api/fight/[id]/route.ts | 25 ++ web/src/app/fight/[id]/FightCardClient.tsx | 114 +++++++ web/src/app/fight/[id]/page.tsx | 20 ++ web/src/components/vyndr/ArchetypeBadge.tsx | 21 +- web/src/components/vyndr/FightCard.tsx | 259 ++++++++++++++ web/src/components/vyndr/index.ts | 2 + web/src/config/sports.ts | 4 +- web/src/lib/archetypes.js | 43 ++- web/src/lib/vyndrTokens.js | 3 + 22 files changed, 1525 insertions(+), 18 deletions(-) create mode 100644 src/routes/combat.js create mode 100644 src/services/adapters/combatAdapter.js create mode 100644 tests/unit/combatAdapter.test.js create mode 100644 tests/unit/combatArchetypes.test.js create mode 100644 tests/unit/combatFightCard.test.js create mode 100644 web/src/app/api/combat/[date]/route.ts create mode 100644 web/src/app/api/fight/[id]/route.ts create mode 100644 web/src/app/fight/[id]/FightCardClient.tsx create mode 100644 web/src/app/fight/[id]/page.tsx create mode 100644 web/src/components/vyndr/FightCard.tsx diff --git a/src/app.js b/src/app.js index a56485c..b8e6f7f 100644 --- a/src/app.js +++ b/src/app.js @@ -152,6 +152,12 @@ app.use('/api/widget', widgetRoutes); // stat-filtered views over all of them. const scheduleRoutes = require('./routes/schedule'); app.use('/api/schedule', scheduleRoutes); +// Wave 6 β€” combat intelligence (MMA/UFC): fight cards + tale-of-the-tape + +// best-effort ML/round-total odds. Read-only, cache-friendly, honest empty +// off-card. NOT in the graded-props pipeline (no settled grades in v1). +const combatRoutes = require('./routes/combat'); +app.use('/api/combat', combatRoutes); +app.use('/api/fight', combatRoutes.fightRouter); // Session 45 β€” live ticker feed (snapshot exhaust + editorial pins). Public, // cache-only, never triggers a snapshot. const tickerRoutes = require('./routes/ticker'); diff --git a/src/config/sports.js b/src/config/sports.js index 801ed4a..a98dd83 100644 --- a/src/config/sports.js +++ b/src/config/sports.js @@ -41,7 +41,11 @@ const SPORTS = Object.freeze({ nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' }, nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' }, tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' }, - mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' }, + // Wave 6 β€” combat intelligence: MMA is live as a READ surface (fight cards + + // tale-of-the-tape + style blend + ML/round-total odds). It is NOT in the + // graded-props pipeline (SPORT_CONFIG) or the snapshot/settle loop yet, so + // collectData stays false β€” active flips true so the UI treats it as live. + mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false }, boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' }, golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' }, }); diff --git a/src/routes/combat.js b/src/routes/combat.js new file mode 100644 index 0000000..1a00ea3 --- /dev/null +++ b/src/routes/combat.js @@ -0,0 +1,81 @@ +/** + * /api/combat/:date + /api/fight/:id (Wave 6 β€” combat intelligence, honest v1). + * + * Fight-card discovery + tale-of-the-tape + best-effort moneyline / round-total + * odds. FREE ESPN MMA feed for the cards; the paid odds-api feed for ML/round + * totals is BEST-EFFORT + cached (never fails the card). Combat is NOT in the + * snapshot/settle loop β†’ NO settled grades here; method/round/KO are surfaced by + * the frontend as honest "β€” data-limited", never fabricated. + * + * Response (cards): + * { date, events: [ { id, name, shortName, date, venue, + * bouts: [ { id, weightClass, rounds, status, fighters: [ …tale ], + * odds: { moneyline, roundTotal } | null } ] } ], source } + */ + +const express = require('express'); +const combat = require('../services/adapters/combatAdapter'); +const { createRateLimit } = require('../middleware/rateLimit'); + +const router = express.Router(); +// Public throttle (60/min; ESPN is free, odds are cached β€” be respectful). +router.use(createRateLimit({ windowMs: 60_000, max: 60 })); + +const MISSION_HEADER = { 'X-VYNDR-Mission': 'Styles make fights' }; + +// Attach cached/best-effort odds onto each bout of each event. +function attachOdds(events, oddsMap) { + for (const ev of events || []) { + for (const bout of ev.bouts || []) { + const o = combat.matchBoutOdds(bout, oddsMap); + bout.odds = o ? { moneyline: o.moneyline, roundTotal: o.roundTotal } : null; + } + } + return events; +} + +// GET /api/combat/:date β€” fight cards for an ET date (defaults to today). +router.get('/:date', async (req, res) => { + const date = /^\d{4}-\d{2}-\d{2}$/.test(String(req.params.date || '')) + ? req.params.date + : combat.todayET(); + try { + const [cards, oddsMap] = await Promise.all([ + combat.getFightCards(date), + combat.getCombatOdds().catch(() => ({})), + ]); + attachOdds(cards.events, oddsMap); + res.set('Cache-Control', 'public, max-age=300'); + return res.set(MISSION_HEADER).json(cards); + } catch (err) { + console.error('[combat/:date]', err && err.message); + // The board is never a crash β€” honest empty on failure. + return res.set(MISSION_HEADER).json({ date, events: [], source: 'espn' }); + } +}); + +module.exports = router; + +// Separate router for /api/fight/:id (a single card by event id). +const fightRouter = express.Router(); +fightRouter.use(createRateLimit({ windowMs: 60_000, max: 60 })); + +fightRouter.get('/:id', async (req, res) => { + const id = String(req.params.id || '').replace(/[^0-9]/g, ''); + if (!id) return res.status(404).set(MISSION_HEADER).json({ error: 'not found' }); + try { + const [card, oddsMap] = await Promise.all([ + combat.getFightCard(id), + combat.getCombatOdds().catch(() => ({})), + ]); + if (!card) return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' }); + attachOdds([card], oddsMap); + res.set('Cache-Control', 'public, max-age=300'); + return res.set(MISSION_HEADER).json({ event: card, source: 'espn' }); + } catch (err) { + console.error('[fight/:id]', err && err.message); + return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' }); + } +}); + +module.exports.fightRouter = fightRouter; diff --git a/src/services/adapters/combatAdapter.js b/src/services/adapters/combatAdapter.js new file mode 100644 index 0000000..0845e7f --- /dev/null +++ b/src/services/adapters/combatAdapter.js @@ -0,0 +1,358 @@ +/** + * combatAdapter β€” ESPN MMA/UFC (FREE JSON, no auth) β†’ normalized fight cards + * + tale-of-the-tape (Wave 6, combat intelligence). + * + * DATA SEMANTICS: fighter records / physicals are REAL sourced facts. We never + * fabricate. `Number(null) === 0` is the trap β€” an absent stat (reach, stance, + * finish counts) stays ABSENT (null), never coerced to 0. ESPN's MMA striking/ + * grappling granularity is THINNER than ufcstats; when a field is absent the + * tape says less, never invents. + * + * Source: site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard + * - The scoreboard `events[]` are UFC CARDS. Each event carries many + * `competitions[]` β€” one per BOUT. Each bout has 2 competitors (fighters) + * with athlete name, W-L-D record, weight class, scheduled rounds, and the + * ESPN athlete id (parsed from the player-card link href). Stance/reach are + * NOT in the free scoreboard β†’ left null (absent), wired for a later enrich. + * + * Odds (moneyline + round total) come from the odds-api MMA feed and are parsed + * by the PURE `normalizeCombatOdds` here (odds-api event shape β†’ per-bout ML + + * round total). VYNDR never generates odds β€” these are REAL book numbers. + * + * Everything is defensive: an unrecognized shape yields an empty result, never + * a throw. `fetchImpl` is injectable so tests never touch the network. + */ + +const axios = require('axios'); +const { cacheGet, cacheSet } = require('../../utils/redis'); +const { ALLOWED_BOOKS } = require('../../utils/oddsNormalizer'); + +const ESPN_MMA_SCOREBOARD = 'https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard'; +const HTTP_TIMEOUT_MS = 10_000; +const CARDS_TTL = 15 * 60; // 15 min β€” cards move slowly; keep it cheap +const STALE_TTL = 6 * 3600; // stale-while-error fallback + +/** Real finite number or null β€” the absent-beats-zero guard. */ +function numOrNull(v) { + if (v === null || v === undefined || v === '') return null; + const n = Number(v); + return Number.isFinite(n) ? n : null; +} + +/** Today's ET date (YYYY-MM-DD). Fight days roll on ET like the other sports. */ +function todayET() { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(new Date()); +} + +/** ET date (YYYY-MM-DD) of an ISO timestamp, or null if unparseable. */ +function dateET(iso) { + if (!iso) return null; + const t = new Date(iso); + if (Number.isNaN(t.getTime())) return null; + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(t); +} + +/** Extract the ESPN athlete id from a fighter's link hrefs (…/id/4801725/…). */ +function parseAthleteId(links) { + if (!Array.isArray(links)) return null; + for (const l of links) { + const m = /\/id\/(\d+)\//.exec(l && l.href ? String(l.href) : ''); + if (m) return m[1]; + } + return null; +} + +/** Parse a "W-L-D" record summary into structured parts. Absent β†’ nulls. */ +function parseRecord(competitor) { + const recs = competitor && Array.isArray(competitor.records) ? competitor.records : []; + const overall = recs.find((r) => r && (r.type === 'total' || r.name === 'overall')) || recs[0]; + const summary = overall && typeof overall.summary === 'string' ? overall.summary : null; + let wins = null, losses = null, draws = null; + if (summary) { + const m = /^(\d+)\s*-\s*(\d+)(?:\s*-\s*(\d+))?/.exec(summary.trim()); + if (m) { + wins = numOrNull(m[1]); + losses = numOrNull(m[2]); + draws = m[3] != null ? numOrNull(m[3]) : 0; + } + } + // Display form (26–1 with an en-dash, or 26–1–0 when a draw exists). + let display = null; + if (wins != null && losses != null) { + display = draws ? `${wins}–${losses}–${draws}` : `${wins}–${losses}`; + } + return { wins, losses, draws, summary, display }; +} + +/** Normalize ONE fighter (an ESPN competitor). Defensive; unknown fields null. */ +function normalizeFighter(competitor) { + if (!competitor || typeof competitor !== 'object') return null; + const a = competitor.athlete || {}; + const name = a.displayName || a.fullName || a.shortName || null; + if (!name) return null; + return { + id: parseAthleteId(a.links), + name, + shortName: a.shortName || null, + record: parseRecord(competitor), + winner: competitor.winner === true ? true : (competitor.winner === false ? false : null), + // Physicals absent from the free scoreboard β€” kept for a future enrich. + stance: null, + reach: null, + }; +} + +/** Normalize ONE bout (an ESPN competition). Returns null on an unusable shape. */ +function normalizeBout(comp) { + if (!comp || typeof comp !== 'object') return null; + const competitors = Array.isArray(comp.competitors) ? comp.competitors : []; + if (competitors.length < 2) return null; + const ordered = [...competitors].sort((x, y) => (x.order ?? 0) - (y.order ?? 0)); + const fighters = ordered.map(normalizeFighter).filter(Boolean); + if (fighters.length < 2) return null; + const st = comp.status && comp.status.type ? comp.status.type : {}; + return { + id: comp.id != null ? String(comp.id) : null, + weightClass: (comp.type && (comp.type.text || comp.type.abbreviation)) || null, + rounds: numOrNull(comp.format && comp.format.regulation && comp.format.regulation.periods), + status: st.state || null, // pre | in | post + completed: st.completed === true, + fighters, + }; +} + +/** + * Normalize a raw ESPN scoreboard payload into VYNDR fight cards. PURE + + * defensive β€” a shape it doesn't recognize returns { events: [] }, never throws. + */ +function normalizeScoreboard(raw, { date } = {}) { + const events = raw && Array.isArray(raw.events) ? raw.events : []; + const cards = []; + for (const ev of events) { + if (!ev || typeof ev !== 'object') continue; + const evDate = ev.date || null; + // Date-pin defensively (same discipline as scheduleService S57): when a + // date is requested, only events on that ET date survive. + if (date && dateET(evDate) !== date) continue; + const comps = Array.isArray(ev.competitions) ? ev.competitions : []; + const bouts = comps.map(normalizeBout).filter(Boolean); + if (bouts.length === 0 && !ev.id) continue; + const comp0 = comps[0] || {}; + cards.push({ + id: ev.id != null ? String(ev.id) : null, + name: ev.name || null, + shortName: ev.shortName || null, + date: evDate, + dateET: dateET(evDate), + venue: (comp0.venue && (comp0.venue.fullName || comp0.venue.shortName)) || null, + bouts, + }); + } + return { events: cards }; +} + +async function doFetch(url, fetchImpl) { + if (fetchImpl) return fetchImpl(url); + const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS }); + return res.data; +} + +/** + * getFightCards(date, opts) β€” cache-aside UFC cards for an ET date. + * opts: { fetchImpl, cacheGet, cacheSet }. NO odds-api credits (ESPN is free). + * Off-card windows return { events: [] } (honest empty), never a throw. + */ +async function getFightCards(date = todayET(), opts = {}) { + const cGet = opts.cacheGet || cacheGet; + const cSet = opts.cacheSet || cacheSet; + const key = `combat:cards:${date}`; + try { + const cached = await cGet(key); + if (cached && Array.isArray(cached.events)) return { ...cached, source: 'cache' }; + } catch { /* cache miss β†’ live */ } + + try { + const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?'; + const url = `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(date).replace(/-/g, '')}`; + const raw = await doFetch(url, opts.fetchImpl); + const normalized = normalizeScoreboard(raw, { date }); + const payload = { date, events: normalized.events, source: 'espn' }; + try { await cSet(key, payload, CARDS_TTL); } catch { /* best-effort */ } + return payload; + } catch (err) { + // Stale-while-error, else honest empty. + try { + const stale = await cGet(key); + if (stale && Array.isArray(stale.events)) return { ...stale, source: 'stale' }; + } catch { /* ignore */ } + console.warn('[combatAdapter] getFightCards failed:', err && err.message); + return { date, events: [], source: 'espn' }; + } +} + +/** + * getFightCard(eventId, opts) β€” one UFC card (all its bouts). The scoreboard + * carries every bout, so we locate the event by id. opts may pass `date` to + * pin the scoreboard fetch. Returns null when the id isn't found. + */ +async function getFightCard(eventId, opts = {}) { + const id = String(eventId || ''); + if (!id) return null; + const cGet = opts.cacheGet || cacheGet; + const cSet = opts.cacheSet || cacheSet; + try { + const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?'; + const url = opts.date + ? `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(opts.date).replace(/-/g, '')}` + : ESPN_MMA_SCOREBOARD; + const key = `combat:card:${id}`; + if (!opts.fetchImpl) { + try { + const cached = await cGet(key); + if (cached && cached.id) return { ...cached, source: 'cache' }; + } catch { /* miss */ } + } + const raw = await doFetch(url, opts.fetchImpl); + // Don't date-filter here β€” we're locating a specific event by id. + const normalized = normalizeScoreboard(raw, {}); + const card = normalized.events.find((e) => e.id === id) || null; + if (card) { try { await cSet(key, card, CARDS_TTL); } catch { /* ignore */ } } + return card; + } catch (err) { + console.warn('[combatAdapter] getFightCard failed:', err && err.message); + return null; + } +} + +/** Best (highest) American-odds price wins for a given side. Absent β†’ null. */ +function bestPrice(current, price) { + const p = numOrNull(price); + if (p == null) return current; + if (current == null) return p; + return p > current ? p : current; // best payout for the bettor +} + +/** + * normalizeCombatOdds(eventsWithOdds) β€” PURE parse of the odds-api MMA event + * odds array into per-bout moneyline + round total. VYNDR never generates + * these β€” they are REAL book numbers. Only allow-listed US books count. + * Returns a map keyed by matchup ("fighterA|fighterB", lowercased) so the + * combat surface can join odds to the ESPN bout. Absent market β†’ absent side. + */ +function normalizeCombatOdds(eventsWithOdds) { + const out = {}; + const list = Array.isArray(eventsWithOdds) ? eventsWithOdds : []; + for (const ev of list) { + if (!ev || typeof ev !== 'object') continue; + const home = ev.home_team || null; + const away = ev.away_team || null; + if (!home && !away) continue; + const ml = { home: null, away: null }; + const roundTotal = { line: null, over: null, under: null }; + const books = Array.isArray(ev.bookmakers) ? ev.bookmakers : []; + for (const bk of books) { + if (!bk || !ALLOWED_BOOKS.has(bk.key)) continue; + const markets = Array.isArray(bk.markets) ? bk.markets : []; + for (const mk of markets) { + const outcomes = Array.isArray(mk && mk.outcomes) ? mk.outcomes : []; + if (mk.key === 'h2h') { + for (const o of outcomes) { + if (o.name === home) ml.home = bestPrice(ml.home, o.price); + else if (o.name === away) ml.away = bestPrice(ml.away, o.price); + } + } else if (mk.key === 'totals') { + for (const o of outcomes) { + const point = numOrNull(o.point); + if (point == null) continue; + if (roundTotal.line == null) roundTotal.line = point; + // Only pair the primary posted line (first seen). + if (point !== roundTotal.line) continue; + if (o.name === 'Over') roundTotal.over = bestPrice(roundTotal.over, o.price); + else if (o.name === 'Under') roundTotal.under = bestPrice(roundTotal.under, o.price); + } + } + } + } + const key = `${String(home || '').toLowerCase()}|${String(away || '').toLowerCase()}`; + out[key] = { + eventId: ev.id != null ? String(ev.id) : null, + home, + away, + commence_time: ev.commence_time || null, + moneyline: ml, + roundTotal: (roundTotal.over != null || roundTotal.under != null) ? roundTotal : null, + }; + } + return out; +} + +const ODDS_API_MMA_ODDS = 'https://api.the-odds-api.com/v4/sports/mma_mixed_martial_arts/odds'; +const ODDS_TTL = 30 * 60; // 30 min β€” combat ML/round totals move slowly; conserve odds-api credits + +/** + * getCombatOdds(opts) β€” BEST-EFFORT, CACHED combat moneyline + round totals. + * Cache-aside on `combat:odds`; on a cold cache it hits the odds-api MMA BULK + * odds endpoint ONCE (h2h + totals in a single request) rather than per-event, + * to conserve the paid odds-api quota. No key / any error β†’ {} (odds absent; + * cards still render, the ML cell shows an honest "β€”"). Never throws. + * opts: { fetchImpl, cacheGet, cacheSet, apiKey }. + */ +async function getCombatOdds(opts = {}) { + const cGet = opts.cacheGet || cacheGet; + const cSet = opts.cacheSet || cacheSet; + const key = 'combat:odds'; + try { + const cached = await cGet(key); + if (cached && typeof cached === 'object' && !opts.fetchImpl) return cached; + } catch { /* miss β†’ live */ } + + const apiKey = opts.apiKey || process.env.ODDS_API_KEY; + if (!apiKey && !opts.fetchImpl) return {}; + try { + const url = `${ODDS_API_MMA_ODDS}?apiKey=${encodeURIComponent(apiKey || '')}®ions=us&markets=h2h,totals&oddsFormat=american`; + const raw = await doFetch(url, opts.fetchImpl); + const map = normalizeCombatOdds(Array.isArray(raw) ? raw : []); + try { await cSet(key, map, ODDS_TTL); } catch { /* best-effort */ } + return map; + } catch (err) { + console.warn('[combatAdapter] getCombatOdds failed:', err && err.message); + return {}; + } +} + +/** + * matchBoutOdds(bout, oddsMap) β€” join the odds map onto an ESPN bout by fighter + * names (either orientation). Returns the odds record or null. Never throws. + */ +function matchBoutOdds(bout, oddsMap) { + if (!bout || !oddsMap || typeof oddsMap !== 'object') return null; + const fs = Array.isArray(bout.fighters) ? bout.fighters : []; + if (fs.length < 2) return null; + const a = String(fs[0].name || '').toLowerCase(); + const b = String(fs[1].name || '').toLowerCase(); + return oddsMap[`${a}|${b}`] || oddsMap[`${b}|${a}`] || null; +} + +module.exports = { + // read paths + getFightCards, + getFightCard, + getCombatOdds, + matchBoutOdds, + // pure, tested transforms + normalizeScoreboard, + normalizeBout, + normalizeFighter, + normalizeCombatOdds, + parseAthleteId, + parseRecord, + // helpers + todayET, + dateET, + numOrNull, + ESPN_MMA_SCOREBOARD, +}; diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js index 3c88931..6a5c080 100644 --- a/src/services/archetypeService.js +++ b/src/services/archetypeService.js @@ -272,7 +272,40 @@ const ARCHETYPES = { }, }; +// ── Combat archetype registry (Wave 6 β€” MMA/UFC) ──────────────────── +// PINNED by specs/combat-intelligence.md. Kept in a SEPARATE registry +// (NOT merged into ARCHETYPES) for two reasons: +// 1. FINISHER collides with the soccer archetype name β€” combat FINISHER +// is a DIFFERENT color/glyph, and the global getArchetype()/ARCHETYPES +// lookup is keyed by uppercase name with no sport dimension. +// 2. Combat FINISHER's green (#12B886) sits close to the signal green, +// which the colorContract gate forbids for shared player archetypes. +// Isolating combat keeps that gate (edge-green purity) intact while +// honoring the pinned combat palette. +// The frontend mirror lives in web/src/lib/archetypes.js COMBAT_ARCHETYPE_MAP; +// tests/unit/combatArchetypes.test.js asserts the two agree (colors + glyphs), +// same discipline as the cross-sport color-match test. +const COMBAT_ARCHETYPES = { + STRIKER: { tag: 'STRIKER', sport: 'mma', color: '#E8703A', glyph: '✦', axis: 'range', description: 'Wins on the feet β€” volume + power at range.' }, + GRAPPLER: { tag: 'GRAPPLER', sport: 'mma', color: '#2FA4E7', glyph: 'βŠ—', axis: 'range', description: 'Fight hits the mat on his terms β€” control + subs.' }, + PRESSURE: { tag: 'PRESSURE', sport: 'mma', color: '#E4574C', glyph: '➀', axis: 'tempo', description: 'Forward, relentless, breaks the pace.' }, + COUNTER: { tag: 'COUNTER', sport: 'mma', color: '#8E7BE0', glyph: 'β—Š', axis: 'tempo', description: 'Patient β€” punishes what you show him.' }, + FINISHER: { tag: 'FINISHER', sport: 'mma', color: '#12B886', glyph: 'β–²', axis: 'outcome', description: 'Ends nights β€” high KO/SUB rate.' }, + GRINDER: { tag: 'GRINDER', sport: 'mma', color: '#B0883B', glyph: 'β–¦', axis: 'outcome', description: 'Goes the distance, wins the rounds.' }, +}; + +// Discipline pedigree tags β€” VERIFIABLE credentials only (rendered separately +// from the archetype blend). Never inferred/guessed: absent when unknown. +const DISCIPLINE_PEDIGREES = [ + 'Combat Sambo', 'Dagestan Wrestling', 'BJJ', 'Wrestling Base', + 'Kickboxing', 'Muay Thai', 'Boxing', +]; + const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0); +// Strict presence check β€” an ABSENT stat must not score an axis (Number(null) +// === 0 would fabricate a "0 output" claim). Only a real finite number counts. +const has = (v) => typeof v === 'number' && Number.isFinite(v); +const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); /** NBA scorers β€” VYNDR Original keys. */ function scoreNBA(s) { @@ -358,7 +391,76 @@ function scoreMLB(s) { }; } -const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB }; +/** + * MMA fighter scorer. Style is a BLEND across three axes: + * range β†’ STRIKER (strikes at distance) ↔ GRAPPLER (mat control + subs) + * tempo β†’ PRESSURE (forward volume) ↔ COUNTER (patient, high defense) + * outcome β†’ FINISHER (KO/SUB rate) ↔ GRINDER (goes the distance) + * + * Best-effort from what the ESPN feed exposes; ESPN's striking/grappling + * granularity is THINNER than ufcstats. Every axis scores ONLY when its + * inputs are real finite numbers β€” thin data yields fewer style claims, + * never a fabricated one. Inputs (all optional): + * slpm/sapm sig strikes landed/absorbed per min + * strAcc/strDef striking accuracy / defense (0-1) + * tdAvg takedowns per 15 + * subAvg sub attempts per 15 + * koRate/subRate/decRate fraction of WINS by method (0-1) + * koWins/subWins/decWins method counts (rates derived if rates absent) + */ +function scoreMMA(s = {}) { + const out = {}; + + // ── range axis ── + if (has(s.slpm)) { + let v = clamp01((s.slpm - 2) / 4); // ~2/min floor, ~6/min elite + if (has(s.tdAvg) && s.tdAvg < 1) v += 0.15; // low takedown reliance = pure striker + if (has(s.strAcc)) v += clamp01((s.strAcc - 0.4) * 1.2) * 0.15; + if (v > 0) out.STRIKER = clamp01(v); + } + if (has(s.tdAvg) || has(s.subAvg)) { + let v = has(s.tdAvg) ? clamp01(s.tdAvg / 4) : 0; // 4 TD/15 β‰ˆ elite control + if (has(s.subAvg)) v += clamp01(s.subAvg / 3) * 0.5; + if (v > 0) out.GRAPPLER = clamp01(v); + } + + // ── tempo axis ── + if (has(s.slpm) && has(s.sapm)) { + const vol = clamp01((s.slpm + s.sapm - 6) / 6); // heavy two-way volume = forward pressure + if (vol > 0) out.PRESSURE = vol; + } + if (has(s.strDef) || has(s.strAcc)) { + let v = has(s.strDef) ? clamp01((s.strDef - 0.55) * 2.2) * 0.6 : 0; + if (has(s.strAcc)) v += clamp01((s.strAcc - 0.45) * 2.2) * 0.4; + if (has(s.slpm) && s.slpm > 4.5) v -= 0.2; // a high-output striker isn't a patient counter + if (v > 0) out.COUNTER = clamp01(v); + } + + // ── outcome axis ── + const koR = has(s.koRate) ? s.koRate : deriveRate(s.koWins, s); + const subR = has(s.subRate) ? s.subRate : deriveRate(s.subWins, s); + const decR = has(s.decRate) ? s.decRate : deriveRate(s.decWins, s); + if (koR != null || subR != null) { + const finish = (koR || 0) + (subR || 0); + if (finish > 0) out.FINISHER = clamp01(finish); + } + if (decR != null && decR > 0) out.GRINDER = clamp01(decR); + + return out; +} + +// Derive a method rate from a win count when explicit rates are absent. +// Returns null (not 0) when totals are unknown β€” absent, never fabricated. +function deriveRate(count, s) { + if (!has(count)) return null; + const total = has(s.totalWins) + ? s.totalWins + : (has(s.koWins) ? s.koWins : 0) + (has(s.subWins) ? s.subWins : 0) + (has(s.decWins) ? s.decWins : 0); + if (!total || total <= 0) return null; + return clamp01(count / total); +} + +const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB, mma: scoreMMA }; /** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */ function getArchetype(name) { @@ -370,6 +472,13 @@ function getArchetype(name) { return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null; } +/** Look up a COMBAT archetype descriptor by name (case-insensitive). */ +function getCombatArchetype(name) { + if (!name) return null; + const key = String(name).toUpperCase(); + return COMBAT_ARCHETYPES[key] ? { name: key, ...COMBAT_ARCHETYPES[key] } : null; +} + /** * Classify a player. Returns: * { sport, primary, secondary|null, blend: [{archetype, weight}] } @@ -384,7 +493,12 @@ function classify(sport, stats = {}) { .filter(([, v]) => v > 0) .sort((a, b) => b[1] - a[1]); + // Combat is HONEST-empty on thin data: no forced fallback archetype (the + // other sports fall back to a low-usage role, but inventing a fighter's + // style from no data would be a fabrication β€” spec Β§STYLE-MATCHUP). + const resolver = sp === 'mma' ? getCombatArchetype : getArchetype; if (ranked.length === 0) { + if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] }; const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR'; return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] }; } @@ -393,23 +507,88 @@ function classify(sport, stats = {}) { const total = top.reduce((sum, [, v]) => sum + v, 0) || 1; const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) })); - const primary = getArchetype(ranked[0][0]); + const primary = resolver(ranked[0][0]); const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4 - ? getArchetype(ranked[1][0]) + ? resolver(ranked[1][0]) : null; return { sport: sp, primary, secondary, blend }; } +/** Weight of an archetype within a blend (0 when absent). */ +function blendWeight(blend, name) { + const hit = (blend || []).find((b) => b.archetype === name); + return hit ? hit.weight : 0; +} + +// Accept either a classify() result ({ blend }) or a raw blend array. +function asBlend(x) { + if (Array.isArray(x)) return x; + if (x && Array.isArray(x.blend)) return x.blend; + return []; +} + +/** + * styleMatchup(a, b) β€” a DESCRIPTIVE MODEL style-edge read (the mockup's + * CENTER VERDICT). NOT a settled grade, NOT an edge %, NO fabricated + * confidence. Compares two style blends; when the data is too thin or the + * styles are too close to call, it says so honestly. + * + * a/b may be classify('mma', …) results or raw blend arrays. + * Returns { verdict, edgeSide: 'a'|'b'|null, summary }. + */ +const STYLE_AXES = ['GRAPPLER', 'STRIKER', 'PRESSURE', 'COUNTER', 'FINISHER', 'GRINDER']; +const MIN_EDGE = 0.2; // below this stylistic gap β†’ too close to call + +function styleMatchup(a, b) { + const A = asBlend(a); + const B = asBlend(b); + if (A.length === 0 || B.length === 0) { + return { + verdict: 'INSUFFICIENT READ', + edgeSide: null, + summary: 'Not enough style data to call this matchup β€” a MODEL read needs both fighters profiled.', + }; + } + + let best = null; + for (const ax of STYLE_AXES) { + const diff = blendWeight(A, ax) - blendWeight(B, ax); + if (!best || Math.abs(diff) > Math.abs(best.diff)) best = { ax, diff }; + } + + if (!best || Math.abs(best.diff) < MIN_EDGE) { + return { + verdict: 'STYLES EVEN', + edgeSide: null, + summary: 'Two closely matched styles β€” no clear stylistic edge. A MODEL read, not a graded pick.', + }; + } + + const edgeSide = best.diff > 0 ? 'a' : 'b'; + return { + verdict: `${best.ax} EDGE`, + edgeSide, + summary: `${best.ax} advantage tilts this on style β€” a MODEL read, not a settled grade.`, + }; +} + const classifyNBA = (stats) => classify('nba', stats); const classifyWNBA = (stats) => classify('wnba', stats); const classifyMLB = (stats) => classify('mlb', stats); +const classifyMMA = (stats) => classify('mma', stats); + module.exports = { ARCHETYPES, + COMBAT_ARCHETYPES, + DISCIPLINE_PEDIGREES, getArchetype, + getCombatArchetype, classify, classifyNBA, classifyWNBA, classifyMLB, + classifyMMA, + styleMatchup, }; diff --git a/src/services/oddsService.js b/src/services/oddsService.js index f2f350c..f2587b8 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -56,6 +56,11 @@ const SPORT_KEYS = { // keys added in Session 31; NHL keys were added alongside this wiring. nfl: 'americanfootball_nfl', nhl: 'icehockey_nhl', + // MMA / UFC (Wave 6 β€” combat intelligence). odds-api sport key for the + // moneyline + round-total feed. PropLine carries NO combat β†’ odds-api-only + // (combat never enters the abundant player-props path). Off-card windows + // return an empty events array and the combat surface self-hides honestly. + mma: 'mma_mixed_martial_arts', // Soccer (Session 7j) β€” odds-api sport keys verified against // https://the-odds-api.com/sports-odds-data/sports-apis.html soccer_wc: 'soccer_fifa_world_cup', @@ -142,6 +147,10 @@ const SOCCER_MARKETS = [ 'player_passes', 'team_clean_sheet', ]; +// MMA / UFC (Wave 6) β€” GAME-level markets only: moneyline (h2h) + round total +// (totals). No player props on the free feed β†’ NO 'spreads' suffix (unlike the +// player-prop sports, whose buildMarketString appends spreads). +const MMA_MARKETS = ['h2h', 'totals']; function buildMarketString(markets) { return [...markets, 'spreads'].join(','); @@ -155,6 +164,8 @@ const SPORT_MARKETS = Object.freeze({ mlb: buildMarketString(MLB_MARKETS), nfl: buildMarketString(NFL_MARKETS), nhl: buildMarketString(NHL_MARKETS), + // MMA carries no player-prop spreads β†’ join the game-level markets directly. + mma: MMA_MARKETS.join(','), ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA // Every soccer league code shares the same market set. ...Object.fromEntries( @@ -499,6 +510,8 @@ module.exports = { getCacheKey, SPORT_KEYS, SOCCER_SPORT_KEYS, + // Wave 6 β€” combat game-level markets (moneyline + round total). + MMA_MARKETS, // Session 16 β€” per-sport market scoping. SPORT_MARKETS, getMarketsForSport, diff --git a/src/utils/oddsNormalizer.js b/src/utils/oddsNormalizer.js index 3d504fc..86bc1ef 100644 --- a/src/utils/oddsNormalizer.js +++ b/src/utils/oddsNormalizer.js @@ -83,6 +83,16 @@ const MARKET_MAP = { // are shared with soccer/NBA β€” sport context discriminates downstream. player_shots_on_goal: 'shots_on_goal', goalie_saves: 'saves', + + // MMA / UFC (Wave 6 β€” combat intelligence). Game-level markets, NOT player + // props: h2h = moneyline (per fighter), totals = round total (over/under). + // Mapped here so combat odds don't silently normalize to zero when wired + // through the odds-api path (same silent-failure class as the MLB/NHL gaps). + // The combat surface parses these via combatAdapter.normalizeCombatOdds β€” + // normalizeProps (this file) is player-prop-shaped and skips them, which is + // correct: MMA carries no per-player point props on the free feed. + h2h: 'moneyline', + totals: 'round_total', }; function normalizeProps(eventsWithOdds) { diff --git a/tests/unit/combatAdapter.test.js b/tests/unit/combatAdapter.test.js new file mode 100644 index 0000000..ee3a672 --- /dev/null +++ b/tests/unit/combatAdapter.test.js @@ -0,0 +1,185 @@ +// Wave 6 β€” combatAdapter defensive parse. Fixtures modeled on the REAL ESPN +// MMA scoreboard shape (site.api.espn.com/.../mma/ufc/scoreboard) captured +// live during the build. NO network β€” fetchImpl + cache are injected. + +const combat = require('../../src/services/adapters/combatAdapter'); + +// A trimmed but structurally-faithful ESPN MMA scoreboard payload: one UFC +// event ("card") with two bouts. Athlete ids live in the player-card link href. +const ESPN_FIXTURE = { + events: [ + { + id: '600059599', + name: 'UFC Fight Night: Du Plessis vs. Usman', + shortName: 'UFC Fight Night', + date: '2026-07-18T21:00Z', + competitions: [ + { + id: '1', + type: { id: '1007', abbreviation: 'W Flyweight', text: "Women's Flyweight" }, + format: { regulation: { periods: 5 } }, + venue: { fullName: 'UFC APEX' }, + status: { type: { state: 'pre', completed: false } }, + competitors: [ + { + id: '10', order: 0, winner: false, + athlete: { + fullName: 'Dricus du Plessis', displayName: 'Dricus du Plessis', shortName: 'D. du Plessis', + links: [{ href: 'https://www.espn.com/mma/fighter/_/id/4801725/dricus-du-plessis' }], + }, + records: [{ name: 'overall', type: 'total', summary: '22-2-0' }], + }, + { + id: '11', order: 1, winner: false, + athlete: { + fullName: 'Kamaru Usman', displayName: 'Kamaru Usman', shortName: 'K. Usman', + links: [{ href: 'https://www.espn.com/mma/fighter/_/id/3088843/kamaru-usman' }], + }, + records: [{ name: 'overall', type: 'total', summary: '20-4-0' }], + }, + ], + }, + { + id: '2', + type: { abbreviation: 'Lightweight' }, + format: { regulation: { periods: 3 } }, + competitors: [ + { id: '20', order: 0, athlete: { displayName: 'Fighter A', links: [] }, records: [{ type: 'total', summary: '10-0' }] }, + { id: '21', order: 1, athlete: { displayName: 'Fighter B', links: [] }, records: [{ type: 'total', summary: '8-3' }] }, + ], + }, + ], + }, + ], +}; + +describe('combatAdapter.normalizeScoreboard β€” ESPN shape β†’ fight cards', () => { + it('normalizes a real-shaped payload into a card with bouts + tale-of-tape', () => { + const { events } = combat.normalizeScoreboard(ESPN_FIXTURE); + expect(events).toHaveLength(1); + const card = events[0]; + expect(card.id).toBe('600059599'); + expect(card.name).toMatch(/Du Plessis/); + expect(card.bouts).toHaveLength(2); + + const bout = card.bouts[0]; + expect(bout.weightClass).toBe("Women's Flyweight"); + expect(bout.rounds).toBe(5); + expect(bout.fighters).toHaveLength(2); + + const a = bout.fighters[0]; + expect(a.name).toBe('Dricus du Plessis'); + expect(a.id).toBe('4801725'); // parsed from the link href + expect(a.record.wins).toBe(22); + expect(a.record.losses).toBe(2); + expect(a.record.display).toBe('22–2'); // en-dash display form + // Physicals absent from the free feed β†’ null, NEVER fabricated 0. + expect(a.stance).toBeNull(); + expect(a.reach).toBeNull(); + }); + + it('date-pins defensively β€” an off-date event is dropped', () => { + const onDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-07-18' }); + expect(onDate.events).toHaveLength(1); + const offDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-01-01' }); + expect(offDate.events).toHaveLength(0); + }); + + it('DEFENSIVE: unrecognized/garbage shapes return empty, never throw', () => { + expect(() => combat.normalizeScoreboard(null)).not.toThrow(); + expect(combat.normalizeScoreboard(null).events).toEqual([]); + expect(combat.normalizeScoreboard({}).events).toEqual([]); + expect(combat.normalizeScoreboard({ events: 'nope' }).events).toEqual([]); + // An identifiable event with only unusable bouts is kept with empty bouts. + expect(combat.normalizeScoreboard({ events: [{ id: 'x', competitions: [{ competitors: [{}] }] }] }).events[0].bouts).toEqual([]); + // An event with no id AND no usable bouts is dropped entirely. + expect(combat.normalizeScoreboard({ events: [{ competitions: [{ competitors: [{}] }] }] }).events).toEqual([]); + }); + + it('numOrNull never coerces null/empty to 0 (the fabrication trap)', () => { + expect(combat.numOrNull(null)).toBeNull(); + expect(combat.numOrNull('')).toBeNull(); + expect(combat.numOrNull(undefined)).toBeNull(); + expect(combat.numOrNull('5')).toBe(5); + expect(combat.numOrNull(0)).toBe(0); + }); + + it('parseAthleteId is defensive on bad links', () => { + expect(combat.parseAthleteId(null)).toBeNull(); + expect(combat.parseAthleteId([{ href: 'no-id-here' }])).toBeNull(); + expect(combat.parseAthleteId([{ href: '/mma/fighter/_/id/999/x' }])).toBe('999'); + }); +}); + +describe('combatAdapter.getFightCards β€” injectable, no network', () => { + it('fetches via injected fetchImpl + normalizes (cache stubbed)', async () => { + const calls = []; + const res = await combat.getFightCards('2026-07-18', { + fetchImpl: async (url) => { calls.push(url); return ESPN_FIXTURE; }, + cacheGet: async () => null, + cacheSet: async () => true, + }); + expect(res.events).toHaveLength(1); + expect(calls[0]).toMatch(/dates=20260718/); + }); + + it('returns honest empty on a fetch error, never throws', async () => { + const res = await combat.getFightCards('2026-07-18', { + fetchImpl: async () => { throw new Error('network'); }, + cacheGet: async () => null, + cacheSet: async () => true, + }); + expect(res.events).toEqual([]); + }); +}); + +describe('combatAdapter.normalizeCombatOdds β€” odds-api MMA β†’ ML + round total', () => { + const ODDS_FIXTURE = [ + { + id: 'evt1', commence_time: '2026-07-18T21:00Z', + home_team: 'Dricus du Plessis', away_team: 'Kamaru Usman', + bookmakers: [ + { + key: 'draftkings', + markets: [ + { key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -230 }, { name: 'Kamaru Usman', price: 190 }] }, + { key: 'totals', outcomes: [{ name: 'Over', price: -110, point: 2.5 }, { name: 'Under', price: -110, point: 2.5 }] }, + ], + }, + { + key: 'fanduel', + markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -215 }, { name: 'Kamaru Usman', price: 200 }] }], + }, + // A non-allow-listed book must be ignored. + { key: 'bovada', markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -999 }] }] }, + ], + }, + ]; + + it('maps h2h to per-fighter moneyline (best price) + totals to round total', () => { + const map = combat.normalizeCombatOdds(ODDS_FIXTURE); + const rec = map['dricus du plessis|kamaru usman']; + expect(rec).toBeDefined(); + expect(rec.moneyline.home).toBe(-215); // best (higher) of -230 / -215 + expect(rec.moneyline.away).toBe(200); // best of 190 / 200 + expect(rec.roundTotal.line).toBe(2.5); + expect(rec.roundTotal.over).toBe(-110); + }); + + it('ignores non-allow-listed books (bovada never leaks a price)', () => { + const map = combat.normalizeCombatOdds(ODDS_FIXTURE); + expect(map['dricus du plessis|kamaru usman'].moneyline.home).not.toBe(-999); + }); + + it('matchBoutOdds joins in either name orientation', () => { + const map = combat.normalizeCombatOdds(ODDS_FIXTURE); + const bout = { fighters: [{ name: 'Kamaru Usman' }, { name: 'Dricus du Plessis' }] }; + expect(combat.matchBoutOdds(bout, map)).toBeTruthy(); + }); + + it('DEFENSIVE: garbage odds input returns {}, never throws', () => { + expect(() => combat.normalizeCombatOdds(null)).not.toThrow(); + expect(combat.normalizeCombatOdds(null)).toEqual({}); + expect(combat.normalizeCombatOdds([{ bookmakers: 'x' }])).toEqual({}); + }); +}); diff --git a/tests/unit/combatArchetypes.test.js b/tests/unit/combatArchetypes.test.js new file mode 100644 index 0000000..4bace21 --- /dev/null +++ b/tests/unit/combatArchetypes.test.js @@ -0,0 +1,108 @@ +// Wave 6 β€” combat intelligence: archetype registry cross-file match, +// classify('mma') blends from fixtures (thin data β†’ fewer claims, never +// fabricated), and styleMatchup honesty. NO network. + +const svc = require('../../src/services/archetypeService'); +const arch = require('../../web/src/lib/archetypes'); + +describe('combat archetype registry β€” pinned + cross-file agreement', () => { + const PINNED = { + STRIKER: { color: '#E8703A', glyph: '✦' }, + GRAPPLER: { color: '#2FA4E7', glyph: 'βŠ—' }, + PRESSURE: { color: '#E4574C', glyph: '➀' }, + COUNTER: { color: '#8E7BE0', glyph: 'β—Š' }, + FINISHER: { color: '#12B886', glyph: 'β–²' }, + GRINDER: { color: '#B0883B', glyph: 'β–¦' }, + }; + + it('backend COMBAT_ARCHETYPES carries exactly the six pinned styles', () => { + expect(Object.keys(svc.COMBAT_ARCHETYPES).sort()).toEqual(Object.keys(PINNED).sort()); + }); + + it('backend colors + glyphs match the pinned spec', () => { + for (const [name, p] of Object.entries(PINNED)) { + expect(svc.COMBAT_ARCHETYPES[name].color).toBe(p.color); + expect(svc.COMBAT_ARCHETYPES[name].glyph).toBe(p.glyph); + } + }); + + it('frontend COMBAT_ARCHETYPE_MAP colors + glyph chars MATCH the backend', () => { + for (const [name, a] of Object.entries(svc.COMBAT_ARCHETYPES)) { + const front = arch.COMBAT_ARCHETYPE_MAP[name]; + expect(front).toBeDefined(); + expect(front.c).toBe(a.color); + expect(front.char).toBe(a.glyph); + } + // No extra frontend combat archetypes beyond the pinned six. + expect(Object.keys(arch.COMBAT_ARCHETYPE_MAP).sort()).toEqual(Object.keys(svc.COMBAT_ARCHETYPES).sort()); + }); + + it('combat FINISHER is namespaced β€” it does NOT collide with the soccer FINISHER', () => { + // Soccer FINISHER stays #FF5C5C in the shared map; combat FINISHER is #12B886. + expect(arch.ARCHETYPE_MAP.FINISHER.c).toBe('#FF5C5C'); + expect(arch.combatArchetypeColor('FINISHER')).toBe('#12B886'); + // sport-aware resolution keeps them apart: + expect(arch.archetypeColor('FINISHER')).toBe('#FF5C5C'); // no sport β†’ soccer + expect(arch.archetypeColor('FINISHER', 'mma')).toBe('#12B886'); // mma β†’ combat + }); +}); + +describe("classify('mma', …) β€” blends from fixtures", () => { + it('a high-volume distance striker profiles STRIKER-primary', () => { + const r = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, strDef: 0.62, tdAvg: 0.2, koRate: 0.6, decRate: 0.3 }); + expect(r.primary && r.primary.name).toBe('STRIKER'); + expect(r.blend.length).toBeGreaterThan(0); + expect(r.blend.map((b) => b.archetype)).toContain('STRIKER'); + }); + + it('a takedown-heavy submission threat profiles GRAPPLER-primary', () => { + const r = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, sapm: 2, strDef: 0.5, koRate: 0.1, subRate: 0.5, decRate: 0.4 }); + expect(r.primary && r.primary.name).toBe('GRAPPLER'); + }); + + it('a high-finish record profiles FINISHER via method rates', () => { + const r = svc.classify('mma', { koWins: 10, subWins: 5, decWins: 1, totalWins: 16 }); + expect(r.blend.map((b) => b.archetype)).toContain('FINISHER'); + }); + + it('THIN data yields NO fabricated style β€” empty blend, null primary (honest)', () => { + const r = svc.classify('mma', { record: '9-4-0' }); // no strike/td/method inputs + expect(r.blend).toEqual([]); + expect(r.primary).toBeNull(); + expect(r.secondary).toBeNull(); + }); + + it('an absent stat never scores an axis (Number(null) === 0 guard)', () => { + const r = svc.classify('mma', { slpm: null, tdAvg: undefined }); + expect(r.blend).toEqual([]); + }); +}); + +describe('styleMatchup β€” honest MODEL read, never a fabricated grade', () => { + const striker = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, tdAvg: 0.2, koRate: 0.6 }); + const grappler = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, subRate: 0.5, decRate: 0.4 }); + + it('divergent styles produce a clear edge to one side (no confidence %)', () => { + const v = svc.styleMatchup(striker, grappler); + expect(v.verdict).toMatch(/EDGE$/); + expect(['a', 'b']).toContain(v.edgeSide); + expect(v).not.toHaveProperty('edge'); // no fabricated edge % + expect(v).not.toHaveProperty('confidence'); // no fabricated confidence + }); + + it('thin data on either side β†’ INSUFFICIENT READ', () => { + expect(svc.styleMatchup(striker, svc.classify('mma', {})).verdict).toBe('INSUFFICIENT READ'); + expect(svc.styleMatchup([], grappler).verdict).toBe('INSUFFICIENT READ'); + }); + + it('two near-identical style blends β†’ STYLES EVEN, no edge side', () => { + const v = svc.styleMatchup(striker, striker); + expect(v.verdict).toBe('STYLES EVEN'); + expect(v.edgeSide).toBeNull(); + }); + + it('accepts raw blend arrays as well as classify() results', () => { + const v = svc.styleMatchup(striker.blend, grappler.blend); + expect(v.verdict).toMatch(/EDGE$|EVEN|INSUFFICIENT/); + }); +}); diff --git a/tests/unit/combatFightCard.test.js b/tests/unit/combatFightCard.test.js new file mode 100644 index 0000000..bdfa4f0 --- /dev/null +++ b/tests/unit/combatFightCard.test.js @@ -0,0 +1,50 @@ +// Wave 6 β€” FightCard honesty locks (source-grep, same discipline as +// colorContract.test.js). The card must: self-hide on a non-two-fighter bout, +// render tale-of-the-tape as MONO data, label the verdict a MODEL read, and +// show method/round/KO as honest "data-limited" β€” never a fabricated grade. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +describe('FightCard.tsx β€” honest v1 tale-of-the-tape', () => { + const src = read('components/vyndr/FightCard.tsx'); + + it('self-hides (returns null) when there arent two named fighters', () => { + expect(src).toMatch(/fighters\.length < 2/); + expect(src).toMatch(/return null/); + }); + + it('data rows are MONO, and no glitch CLASS is applied to any element (data never glitches)', () => { + expect(src).toContain('className="mono"'); + // No glitch animation class on any element (comments about "never glitches" are fine). + expect(src).not.toMatch(/className=["'`][^"'`]*glitch/); + }); + + it('method / round / KO cells are shown as honest "data-limited", not fabricated', () => { + expect(src).toMatch(/dataLimited/); + expect(src).toContain('data-limited'); + expect(src).toMatch(/METHOD/); + }); + + it('the verdict is explicitly labeled a MODEL read, not a settled grade', () => { + expect(src).toContain('MODEL READ'); + expect(src).toContain('INSUFFICIENT READ'); + }); + + it('uses a monogram (no fighter photo / likeness)', () => { + expect(src).toContain('Monogram'); + expect(src).not.toMatch(/headshot|espncdn.*headshots| { + expect(src).toContain('ArchetypeBadge'); + expect(src).toMatch(/sport="mma"/); + }); + + it('absent odds render as a dash, never a fabricated number', () => { + expect(src).toMatch(/const DASH = 'β€”'/); + expect(src).toMatch(/Number\.isFinite/); + }); +}); diff --git a/tests/unit/oddsNormalizer.test.js b/tests/unit/oddsNormalizer.test.js index 165613e..7c3d6ff 100644 --- a/tests/unit/oddsNormalizer.test.js +++ b/tests/unit/oddsNormalizer.test.js @@ -244,4 +244,14 @@ describe('oddsNormalizer', () => { expect(result[0].away_team).toBe('PHX'); }); }); + + // Wave 6 β€” combat (MMA) game-level markets. Without these MARKET_MAP keys, + // combat moneyline/round-total odds would silently normalize to zero (same + // silent-failure class as the MLB/NHL gaps closed earlier). + describe('MMA / combat market keys (Wave 6)', () => { + it('maps h2h β†’ moneyline and totals β†’ round_total', () => { + expect(MARKET_MAP.h2h).toBe('moneyline'); + expect(MARKET_MAP.totals).toBe('round_total'); + }); + }); }); diff --git a/tests/unit/sportMarkets.test.js b/tests/unit/sportMarkets.test.js index 5bdadbb..7210b8b 100644 --- a/tests/unit/sportMarkets.test.js +++ b/tests/unit/sportMarkets.test.js @@ -64,14 +64,23 @@ describe('SPORT_MARKETS β€” isolation', () => { expect(wc).not.toMatch(/batter_/); }); - test('every market list ends with `spreads`', () => { - for (const list of Object.values(SPORT_MARKETS)) { + test('every PLAYER-PROP market list ends with `spreads`', () => { + for (const [sport, list] of Object.entries(SPORT_MARKETS)) { + // Wave 6 β€” MMA is a GAME-level sport (h2h + round totals only); it has + // no player-prop `spreads` market and odds-api 422s if one is sent. + if (sport === 'mma') continue; // We don't require spreads to be the literal final segment, // only that it's present in the comma-separated list. expect(list.split(',')).toContain('spreads'); } }); + test('MMA market list is game-level (h2h + totals), no spreads/player props', () => { + expect(SPORT_MARKETS.mma).toBe('h2h,totals'); + expect(SPORT_MARKETS.mma).not.toMatch(/spreads/); + expect(SPORT_MARKETS.mma).not.toMatch(/player_/); + }); + test('SPORT_MARKETS is frozen at the top level', () => { expect(Object.isFrozen(SPORT_MARKETS)).toBe(true); }); diff --git a/web/src/app/api/combat/[date]/route.ts b/web/src/app/api/combat/[date]/route.ts new file mode 100644 index 0000000..3d38883 --- /dev/null +++ b/web/src/app/api/combat/[date]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** + * Combat (MMA/UFC) fight-cards proxy (Wave 6, S25 rule β€” Express isn't + * reachable from the browser directly). Forwards to /api/combat/:date. + * Off-card windows return an empty-but-valid card list so the UI degrades + * to the honest empty state, never a crash. + */ +export async function GET(req: NextRequest, { params }: { params: Promise<{ date: string }> }) { + const { date } = await params; + const d = String(date || '').toLowerCase(); + try { + const upstream = await fetch(`${BACKEND_URL}/api/combat/${encodeURIComponent(d)}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const data = await upstream.json().catch(() => ({})); + if (!upstream.ok) return NextResponse.json(data, { status: upstream.status }); + return NextResponse.json(data); + } catch { + return NextResponse.json({ date: d, events: [], source: 'espn' }); + } +} diff --git a/web/src/app/api/fight/[id]/route.ts b/web/src/app/api/fight/[id]/route.ts new file mode 100644 index 0000000..711bbc4 --- /dev/null +++ b/web/src/app/api/fight/[id]/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** + * Single fight-card proxy (Wave 6, S25 rule). Forwards to /api/fight/:id. + * Unknown/unavailable card β†’ 404 (honest, no fabricated card). + */ +export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const fid = String(id || '').replace(/[^0-9]/g, ''); + if (!fid) return NextResponse.json({ error: 'not found' }, { status: 404 }); + try { + const upstream = await fetch(`${BACKEND_URL}/api/fight/${encodeURIComponent(fid)}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.status }); + } catch { + return NextResponse.json({ error: 'card not found' }, { status: 404 }); + } +} diff --git a/web/src/app/fight/[id]/FightCardClient.tsx b/web/src/app/fight/[id]/FightCardClient.tsx new file mode 100644 index 0000000..466fe03 --- /dev/null +++ b/web/src/app/fight/[id]/FightCardClient.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { FightCard, EmptyState } from '@/components/vyndr'; +import type { FighterTape } from '@/components/vyndr'; + +interface RawFighter { + id?: string | null; + name: string; + record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null; + stance?: string | null; + reach?: string | number | null; + blend?: { archetype: string; weight: number }[] | null; + pedigrees?: string[] | null; +} +interface RawBout { + id?: string | null; + weightClass?: string | null; + rounds?: number | null; + status?: string | null; + fighters: RawFighter[]; + odds?: { moneyline?: { home?: number | null; away?: number | null } | null; roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null } | null; + verdict?: { verdict: string; edgeSide?: 'a' | 'b' | null; summary?: string } | null; +} +interface RawEvent { + id?: string | null; + name?: string | null; + shortName?: string | null; + date?: string | null; + venue?: string | null; + bouts: RawBout[]; +} + +export default function FightCardClient({ id }: { id: string }) { + const [event, setEvent] = useState(null); + const [state, setState] = useState<'loading' | 'ready' | 'empty'>('loading'); + + useEffect(() => { + let active = true; + fetch(`/api/fight/${encodeURIComponent(id)}`, { headers: { Accept: 'application/json' } }) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!active) return; + const ev: RawEvent | null = d && d.event ? d.event : null; + if (ev && Array.isArray(ev.bouts) && ev.bouts.length > 0) { + setEvent(ev); + setState('ready'); + } else { + setState('empty'); + } + }) + .catch(() => { if (active) setState('empty'); }); + return () => { active = false; }; + }, [id]); + + if (state === 'loading') { + return ( +
    + LOADING THE CARD… +
    + ); + } + + if (state === 'empty' || !event) { + return ( + + ); + } + + const dateStr = event.date ? new Date(event.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : null; + + return ( +
    +
    +
    + {[event.shortName, dateStr, event.venue].filter(Boolean).join(' Β· ') || 'UFC'} +
    +

    + {event.name || 'Fight Card'} +

    +
    + +
    + {event.bouts.map((bout, i) => { + const fighters: FighterTape[] = (bout.fighters || []).slice(0, 2).map((f) => ({ + id: f.id, + name: f.name, + record: f.record, + stance: f.stance, + reach: f.reach, + blend: f.blend, + pedigrees: f.pedigrees, + })); + return ( + + ); + })} +
    +
    + ); +} diff --git a/web/src/app/fight/[id]/page.tsx b/web/src/app/fight/[id]/page.tsx new file mode 100644 index 0000000..125100d --- /dev/null +++ b/web/src/app/fight/[id]/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import FightCardClient from './FightCardClient'; + +/** + * /fight/[id] (Wave 6 β€” combat intelligence). Thin server wrapper for page + * metadata; the interactive tale-of-the-tape cards live in the client + * component. Off-card windows self-hide to the shared EmptyState. + */ +export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { + await params; + return { + title: 'Fight Card β€” VYNDR Combat', + description: 'Tale-of-the-tape, style-blend archetypes, and moneyline / round-total lines for the UFC card. A MODEL style read β€” not a settled grade.', + }; +} + +export default async function FightPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return ; +} diff --git a/web/src/components/vyndr/ArchetypeBadge.tsx b/web/src/components/vyndr/ArchetypeBadge.tsx index f70714e..5451c54 100644 --- a/web/src/components/vyndr/ArchetypeBadge.tsx +++ b/web/src/components/vyndr/ArchetypeBadge.tsx @@ -18,11 +18,12 @@ interface ArchetypeBadgeProps { */ export default function ArchetypeBadge({ archetype, + sport, variant = 'tint', size = 'sm', showDesc = false, }: ArchetypeBadgeProps) { - const s = badgeStyle(archetype, variant, size); + const s = badgeStyle(archetype, variant, size, sport); return ( - + {s.glyphChar ? ( + // Combat glyphs are unicode chars (data never glitches β€” chrome label). + + {s.glyphChar} + + ) : ( + + )} {s.name} {showDesc && s.desc && ( diff --git a/web/src/components/vyndr/FightCard.tsx b/web/src/components/vyndr/FightCard.tsx new file mode 100644 index 0000000..e94aaa8 --- /dev/null +++ b/web/src/components/vyndr/FightCard.tsx @@ -0,0 +1,259 @@ +import ArchetypeBadge from './ArchetypeBadge'; +import SportBadge from './SportBadge'; +import { combatArchetypeColor } from '@/lib/archetypes'; + +/* ============================================================ + FightCard (Wave 6 β€” combat intelligence, honest v1). + The tale-of-the-tape head-to-head from the design mockup: + FIGHTER A Β· CENTER VERDICT Β· FIGHTER B. Two fighters side-by-side + (NOT the player-strip row grammar). All data is MONO and never + glitches. Physicals/records are REAL sourced facts β€” absent fields + render as "β€”", never fabricated. No fighter photos (likeness rule): + an initials monogram only. Style blend + verdict are a MODEL read, + explicitly labeled. Method / round / KO are shown as honest + "β€” data-limited" placeholders (DEFERRED sub-wave), never invented. + ============================================================ */ + +export interface BlendEntry { + archetype: string; + weight: number; // 0-1 +} + +export interface FighterTape { + id?: string | null; + name: string; + record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null; + stance?: string | null; + reach?: string | number | null; + /** Style blend (MODEL) β€” absent when the free feed is too thin to profile. */ + blend?: BlendEntry[] | null; + /** Verifiable discipline credentials only β€” absent when unknown, never guessed. */ + pedigrees?: string[] | null; +} + +export interface FightCardOdds { + moneyline?: { home?: number | null; away?: number | null } | null; + roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null; +} + +export interface FightVerdict { + verdict: string; // e.g. "GRAPPLER EDGE" | "STYLES EVEN" | "INSUFFICIENT READ" + edgeSide?: 'a' | 'b' | null; + summary?: string; +} + +export interface FightCardProps { + weightClass?: string | null; + rounds?: number | null; + status?: string | null; + fighters: FighterTape[]; // [A, B] + odds?: FightCardOdds | null; + verdict?: FightVerdict | null; +} + +const DASH = 'β€”'; +const fmtOdds = (v?: number | null) => (typeof v === 'number' && Number.isFinite(v) ? (v > 0 ? `+${v}` : `${v}`) : DASH); + +function initials(name: string): string { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +/** The two range-axis bars the mockup renders (GRAPPLER% / STRIKER%). */ +function topBars(blend?: BlendEntry[] | null): BlendEntry[] { + if (!Array.isArray(blend) || blend.length === 0) return []; + return [...blend].sort((a, b) => b.weight - a.weight).slice(0, 3); +} + +function Monogram({ name }: { name: string }) { + return ( + + {initials(name)} + + ); +} + +function Fighter({ f, align }: { f: FighterTape; align: 'left' | 'right' }) { + const bars = topBars(f.blend); + const meta: string[] = []; + if (f.record?.display) meta.push(f.record.display); + if (f.stance) meta.push(String(f.stance).toUpperCase()); + if (f.reach != null && f.reach !== '') meta.push(`${f.reach}" REACH`); + const primary = bars[0]?.archetype || null; + const rowDir = align === 'right' ? 'row-reverse' : 'row'; + const textAlign = align === 'right' ? 'right' : 'left'; + + return ( +
    +
    + +
    +
    + {f.name} +
    +
    + {meta.length ? meta.join(' Β· ') : `RECORD ${DASH}`} +
    +
    +
    + + {/* Style-blend bars (MODEL) β€” only when the fighter is profiled. */} + {bars.length > 0 ? ( +
    + {bars.map((b) => { + const c = combatArchetypeColor(b.archetype); + const pct = Math.round((b.weight || 0) * 100); + return ( +
    +
    + + {b.archetype} + + {pct}% +
    +
    +
    +
    +
    + ); + })} +
    + ) : ( +
    + STYLE PROFILE {DASH} DATA-LIMITED +
    + )} + + {primary && ( +
    + +
    + )} + + {/* Discipline pedigree tags β€” verifiable only, absent when unknown. */} + {Array.isArray(f.pedigrees) && f.pedigrees.length > 0 && ( +
    + {f.pedigrees.map((p) => ( + + {p.toUpperCase()} + + ))} +
    + )} +
    + ); +} + +function OddsCell({ label, value, dataLimited }: { label: string; value?: string; dataLimited?: boolean }) { + return ( +
    +
    + {label} +
    + {dataLimited ? ( +
    {DASH} data-limited
    + ) : ( +
    {value}
    + )} +
    + ); +} + +export default function FightCard({ weightClass, rounds, status, fighters, odds, verdict }: FightCardProps) { + // Self-hide honestly if we don't have a two-fighter bout. + if (!Array.isArray(fighters) || fighters.length < 2 || !fighters[0]?.name || !fighters[1]?.name) return null; + const [a, b] = fighters; + + const v = verdict && verdict.verdict ? verdict : { verdict: 'INSUFFICIENT READ', edgeSide: null as null, summary: 'Not enough style data to call this β€” a MODEL read needs both fighters profiled.' }; + const isCall = v.verdict !== 'INSUFFICIENT READ' && v.verdict !== 'STYLES EVEN'; + const edgeStyle = isCall ? v.verdict.replace(/\s+EDGE$/i, '') : null; + const verdictColor = edgeStyle ? combatArchetypeColor(edgeStyle) : 'var(--text-2, #707080)'; + + const ml = odds?.moneyline || null; + const rt = odds?.roundTotal || null; + + return ( +
    + {/* Card header β€” weight class + rounds (mono chrome). */} +
    +
    + + + {[weightClass, rounds ? `${rounds} RD` : null].filter(Boolean).join(' Β· ') || 'BOUT'} + +
    + {status === 'post' && ( + FINAL + )} +
    + + {/* FIGHTER A Β· VERDICT Β· FIGHTER B */} +
    + + +
    +
    VERDICT
    +
    VS
    +
    + {v.verdict} +
    +
    MODEL READ
    +
    + + +
    + + {v.summary && ( +

    + {v.summary} +

    + )} + + {/* Odds row β€” MONEYLINE + round total REAL; method/round/KO data-limited. */} +
    + + + + +
    +

    + Method, round and fighter-prop grades are DATA-LIMITED on the free feed β€” shown as {DASH}, never fabricated. + Odds are REAL book numbers; the style verdict is a MODEL read, not a settled grade. +

    +
    + ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index f5975c2..656ecde 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -23,6 +23,8 @@ export { default as TierRecord } from './TierRecord'; /* Player Intelligence (Session 42) */ export { default as ArchetypeBadge } from './ArchetypeBadge'; +export { default as FightCard } from './FightCard'; +export type { FightCardProps, FighterTape, FightCardOdds, FightVerdict, BlendEntry } from './FightCard'; export { default as ArchetypeBlend } from './ArchetypeBlend'; export type { BlendSegment } from './ArchetypeBlend'; export { default as StatStrip } from './StatStrip'; diff --git a/web/src/config/sports.ts b/web/src/config/sports.ts index f03a5f5..0b2776a 100644 --- a/web/src/config/sports.ts +++ b/web/src/config/sports.ts @@ -22,7 +22,9 @@ export const SPORTS: Record = { nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' }, nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' }, tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' }, - mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' }, + // Wave 6 β€” combat intelligence: MMA is live as a READ surface (fight cards + + // tale-of-the-tape). Not in the graded-props pipeline yet β†’ collectData false. + mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false }, boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' }, golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' }, }; diff --git a/web/src/lib/archetypes.js b/web/src/lib/archetypes.js index 61bb7da..e615273 100644 --- a/web/src/lib/archetypes.js +++ b/web/src/lib/archetypes.js @@ -84,6 +84,21 @@ const ARCHETYPE_MAP = { WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' }, }; +/* Combat archetype visual map (Wave 6 β€” MMA/UFC). SEPARATE from ARCHETYPE_MAP: + FINISHER's combat name/color/glyph differ from the soccer FINISHER, and + combat FINISHER's green is intentionally close to the pitch-green here. Keys/ + colors/glyph CHARS MUST match src/services/archetypeService.js + COMBAT_ARCHETYPES β€” tests/unit/combatArchetypes.test.js asserts it. + `char` is a unicode glyph (rendered as text, not an SVG glyph key). */ +const COMBAT_ARCHETYPE_MAP = { + STRIKER: { c: '#E8703A', d: 'Wins on the feet β€” volume + power at range.', char: '✦', axis: 'range' }, + GRAPPLER: { c: '#2FA4E7', d: 'Fight hits the mat on his terms β€” control + subs.', char: 'βŠ—', axis: 'range' }, + PRESSURE: { c: '#E4574C', d: 'Forward, relentless, breaks the pace.', char: '➀', axis: 'tempo' }, + COUNTER: { c: '#8E7BE0', d: 'Patient β€” punishes what you show him.', char: 'β—Š', axis: 'tempo' }, + FINISHER: { c: '#12B886', d: 'Ends nights β€” high KO/SUB rate.', char: 'β–²', axis: 'outcome' }, + GRINDER: { c: '#B0883B', d: 'Goes the distance, wins the rounds.', char: 'β–¦', axis: 'outcome' }, +}; + const FALLBACK = { c: '#9499A8', d: '', g: '' }; // Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to @@ -93,15 +108,26 @@ for (const [k, v] of Object.entries(ARCHETYPE_MAP)) { if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k; } -function archetypeInfo(name) { +function archetypeInfo(name, sport) { const key = (name == null ? '' : String(name)).toUpperCase(); + // Combat archetypes live in their own namespace (FINISHER collides with the + // soccer archetype) β€” resolve them ONLY when the sport is MMA. + if (String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[key]) { + return COMBAT_ARCHETYPE_MAP[key]; + } if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key]; if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]]; return FALLBACK; } -function archetypeColor(name) { - return archetypeInfo(name).c; +function archetypeColor(name, sport) { + return archetypeInfo(name, sport).c; +} + +/** Combat-only color lookup (unambiguous β€” no soccer FINISHER collision). */ +function combatArchetypeColor(name) { + const key = (name == null ? '' : String(name)).toUpperCase(); + return (COMBAT_ARCHETYPE_MAP[key] || FALLBACK).c; } function glyphSvg(glyphKey) { @@ -114,8 +140,8 @@ function glyphSvg(glyphKey) { * variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default). * size: 'sm' | 'md'. */ -function badgeStyle(name, variant = 'tint', size = 'sm') { - const info = archetypeInfo(name); +function badgeStyle(name, variant = 'tint', size = 'sm', sport) { + const info = archetypeInfo(name, sport); const sm = size === 'sm'; let textColor, bg, borderColor, glyphColor, textShadow = 'none'; if (variant === 'full' || variant === 'solid') { @@ -128,11 +154,14 @@ function badgeStyle(name, variant = 'tint', size = 'sm') { } // Display the canonical VYNDR name even if a legacy name was passed. const upper = (name == null ? '' : String(name)).toUpperCase(); - const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper; + const isCombat = String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[upper]; + const canonical = isCombat ? upper : ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper; return { name: canonical, desc: info.d, glyph: info.g, + // Combat glyphs are unicode chars rendered as TEXT (not SVG glyph keys). + glyphChar: isCombat ? info.char : null, color: info.c, textColor, bg, borderColor, glyphColor, textShadow, fontSize: sm ? '9.5px' : '12px', @@ -146,8 +175,10 @@ function badgeStyle(name, variant = 'tint', size = 'sm') { module.exports = { GLYPHS, ARCHETYPE_MAP, + COMBAT_ARCHETYPE_MAP, archetypeInfo, archetypeColor, + combatArchetypeColor, glyphSvg, badgeStyle, }; diff --git a/web/src/lib/vyndrTokens.js b/web/src/lib/vyndrTokens.js index a506bee..d4b8a29 100644 --- a/web/src/lib/vyndrTokens.js +++ b/web/src/lib/vyndrTokens.js @@ -25,6 +25,9 @@ const SPORT = { mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' }, wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' }, soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' }, + // Wave 6 β€” combat: the #D4AF37 championship-gold token (matches + // src/services/shareCards/tokens.js + config/sports.js mma color). + mma: { label: 'MMA', color: 'var(--s-mma, #d4af37)', hex: '#d4af37' }, }; /* GradeBadge size variants β€” hero stays 80–120px (Β§5: grade letter is From 9ed5bd818b69a910bb04a4e9a5af7833d9719aa7 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 17:02:52 -0400 Subject: [PATCH 15/15] STATE.md: wiring & data train complete (6 waves), ready to merge Co-Authored-By: Claude Opus 4.8 (1M context) --- specs/STATE.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/specs/STATE.md b/specs/STATE.md index 40aab5f..b8874fe 100644 --- a/specs/STATE.md +++ b/specs/STATE.md @@ -1,5 +1,15 @@ # VYNDR β€” STATE OF THE WORLD -### As of `e9c0a59` (main, 2026-07-13). This file opens every future session. Update it when a train ships. +### As of `e9c0a59` (main) + `wiring/data-train` READY, 2026-07-13. This file opens every future session. Update it when a train ships. + +## READY TO MERGE β€” WIRING & DATA TRAIN (branch `wiring/data-train`, NOT on main yet) +Governed by `specs/wiring-data-train.md` (the Step-0 MAP + build plans) + `specs/combat-intelligence.md` + the global visual reference `specs/design-reference/vyndr-system.html` (build toward it; live wordmark kept). **All 6 waves DONE, merged onto `wiring/data-train`, green (253 suites / 3069 tests) + `next build` exit 0. Awaiting the founder's word before main.** Real assets verified live (MLB/ESPN headshot CDNs, ESPN-MMA feed, Baseball Savant CSV). +- **Wave 1 β€” trust bugs:** billing renewal honest render (`billingDisplay.classifyRenewal` β€” no far-future placeholder); James Wood nameKey-collision fixed (`mlbStatsAdapter` teamHint disambiguation + streaks join-invariant); DeskShowcase "$1M terminal" β†’ deadpan copy. +- **Wave 2 β€” sport-agnostic entity layer:** real player headshots threaded from ingestion (MLB MLBAM + NBA/WNBA ESPN athlete ids that were fetched-and-discarded) across slate/scan/hotlist/search/grade card; soccer = honest monogram (no free id); 8 self-authored SVG book wordmarks (`web/public/books/*.svg`, swappable for official art) + all 10 book keys resolve; team-logo abbr aliases. Storage: id on the `enriched` grade at `snapshotService` (zero new I/O). +- **Wave 3 β€” record by grade tier (Addition 2):** ONE shared `TierRecord` (`lib/tierRecord.js` + component) on dashboard + /u + ledger; per-tier W-L always, hit-% only at nβ‰₯20 per tier (gate stays in `getModelAggregate`). +- **Wave 4 β€” missing surfaces:** Outlook mode (grid never blank β†’ yesterday receipts / tomorrow schedule); Market-Breadth median-consensus-vs-model strip (self-hides <2 books); Parlay Lab `/parlay` (slate-independent leg source); live Grade-Shift timeline (`GradeShift`). +- **Wave 5 β€” /u house-mode + arsenal:** house handle `vyndr` surfaces the real `user_id=NULL` public model record + per-tier calibration + 1080Γ—1350 portrait/OG (user-handle privacy 404s stay byte-identical); Baseball Savant pitcher-arsenal (`savantAdapter`, free CSV, verified) β†’ `PitcherArsenal` card, self-hides on absent. +- **Wave 6 β€” combat v1 (MMA):** ESPN-MMA fight cards + tale-of-the-tape + style-blend archetypes (sport-scoped `COMBAT_ARCHETYPES` β€” FINISHER color collided w/ soccer + tripped the Ξ”E gate, so kept separate) + odds-api ML/round-totals + style-edge verdict. NOT in the snapshot/settle loop; method/round/props + fighter photos + matchup-GRADE engine + ufcstats scraping all DEFERRED, flagged data-limited in-UI. New routes: `/fight/[id]`, `/parlay`, `/u/[handle]/portrait`. +POST-MERGE TODO: NBA/WNBA headshot coverage + combat depth need prod runtime verification; soccer headshots blocked on `API_FOOTBALL_KEY`; combat settlement + matchup-grade engine are the next combat sub-wave. ## SHIPPED β€” DESIGN TRAIN v2 (merged to main `e9c0a59`, deployed & fingerprint-verified live) Governed by `specs/DESIGN-SPEC.md` v2 (the raised standard: entities render as