498 Commits

Author SHA1 Message Date
builtbykev 55f5cdc57d P0-3a: dedupe the Explore leaderboard — one row per player+market + per-market cap
Phone audit: leaderboard flooded with 9 consecutive identical 'stolen_bases U0.5
45% B' rows. New shared lib/playerGrouping (dedupeLeaders + groupIntoLadders,
name-key aware, 7 unit tests): ONE row per (player, market family) keeping the
best-ranked, then a per-market cap (4) so no single prop type floods the board.
Applied to ExploreHub. Ledger cards + Consensus grouping follow in P0-3b/c using
the same lib.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:59:08 -04:00
builtbykev c868d1638e P0-2 fix: entity images load EAGERLY — the lazy-defer killed the whole pipeline
Phone audit: 78 ESPN <img> at naturalWidth:0, ZERO espncdn/mlbstatic requests
ever fire — the browser never attempts the fetch. CSP was NOT the cause (img-src
already allows a.espncdn.com + img.mlbstatic.com, confirmed in the live header).
Root cause = native loading="lazy" on the tiny entity <img>s never triggering on
device (the audit's suspect b). TeamLogo + PlayerAvatar now load="eager"
(+ decoding="async") — they're 10-40px core-visual entities, so eager is correct
and cheap, and it forces the fetch on parse regardless of size/viewport.

Browser-request confirmation is the audit's Chrome domain (I can't observe
network from server curls); eager semantics guarantee the fetch once the src
(which 200s server-side) is in the DOM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:53:46 -04:00
builtbykev 489d849f1e P0-1 fix: ledger/u grade badges are token-derived — kill blue-B / amber-C / non-A glow
Phone audit found LEDGER READ CARDS rendering B badges with BLUE borders + C
with AMBER right now in prod — GradePill (components/GradeCard.tsx) hardcoded the
OLD palette (rgba(74,158,255) blue-B, rgba(255,179,71) amber-C) for bg/border
while the text used the migrated token. Migrated bg/border to color-mix on the
grade token, so B renders neutral-white and C grey (matching the board).
- globals.css .grade-*-bg → token-derived color-mix (was raw blue/amber rgba).
- DELETED glow from .grade-glow-b/c/d (glow is A-tier ONLY, by law) — B/C/D keep
  their token color, no text-shadow.
- Purged the last dead grade-blue #4A9EFF fallbacks (SoccerGradeResult, the
  intelligence INFO dot).
- REGRESSION LOCK: vyndrParityQA fails if #4a9eff / rgba(74,158,255) reappears
  anywhere in web/src, if GradePill hardcodes blue/amber rgba, or if grade-glow
  B/C/D grow a text-shadow again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:48:56 -04:00
builtbykev 4f32eab859 Color contract: 'N LIVE' count → green (Design's LIVE color), not red
Design colors every LIVE indicator green (active = green; the live-dots and
LIVE·Q3 labels are all green). The slate header's 'N LIVE' count was red
(--live #ff3b3b) — contradicts the one-meaning-per-color contract (red = miss/
error, which live isn't). Aligned to --g-a.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:02:27 -04:00
builtbykev 429a44160e STATE.md: design completion train substantially shipped; master-audit unverified list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:59:35 -04:00
builtbykev bf8bf16e5c Rev 3 chips: pitcher identity 'vs' — team + opponent TeamChips
PitcherArsenal's meta line showed team + 'vs OPP' as plain text; Rev 3 anchors
the pitcher's team and opponent with TeamChips (real logo / monogram). Combat
FightCard intentionally keeps fighter monograms (no photos — likeness rule).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:58:27 -04:00
builtbykev 6bc9093298 M4 locks: flat edge board is mobile-only + chip/grade/hero + overflow contained
Structural mobile rules become failing tests (M4 'test-lock what's lockable'):
the flat EDGE BOARD shows <768px and game cards are desktop-only; the board
renders TeamChips + tier GradeBadge + sign-colored hero edge% + the ranked
opacity ramp; document never scrolls sideways at 390px. Source assertions —
they lock the RULES, not the pixels (that's the master audit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:48:21 -04:00
builtbykev 8a24ac9129 M1b screen 01: the flat EDGE BOARD — Design's mobile board IA
The one genuinely-new mobile screen. Design's mobile BOARD is a FLAT edge-ranked
list (all graded props across every game on one list, sorted by edge) — not the
desktop's game-grouped cards. Implemented to the drawing with REAL snapshot data:
- slateAdapter.flattenToEdgeBoard(cards) — pure transform of the assembled
  GameCardData[] (grade→game join already done) into ranked rows, edge desc.
  STRICT null edge sorts LAST (never 0-coerced to the top — Data Semantics Rule).
  Threaded edge_pct through buildPlayerStripsFromProps (was dropped). 6 unit tests.
- MobileEdgeBoard component — Design's exact screen-01 rows: rank (green #1),
  player + prop, matchup sub-line with TeamChips + live-dot, tier grade chip,
  and the edge% as the one bold mono hero (green +, red −). Ranked opacity ramp
  (1 → .55) + green inset border on the top reads. Breadth strip EDGES/AVG CLV/
  GAMES — CLV honest '—' (per-slate CLV isn't computed; never fabricated).
- Slate: <768px renders the flat board, ≥768px keeps game cards (same data,
  toggled by width). Ungraded slate still shows game cards on phones (no blank).

Built to Design's screen-01 drawing, VISUALLY UNVERIFIED at 390px — the core
mobile screen, top of the master-audit list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:46:47 -04:00
builtbykev f02dc4a6d5 fix: mobile test pins grade-hero at Design's 74px (follow-up to baf977f)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:34:03 -04:00
builtbykev baf977f3fe M1b grade-reveal (screen 02): Rev 3 team chip + Design mobile hero (74px)
GradeResultCard already carried Design's §7 grade-reveal structure (hero +
identity + 3-col grid + intel box) — the screen was built to the same contract
Design's mobile follows. Applied the Rev 3 gaps: team context now a TeamChip
(real logo/monogram), and the mobile grade hero → 74px (Design's exact mobile
size; M1a had 80). Desktop hero unchanged (116px).

Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:32:58 -04:00
builtbykev f24f9412f1 Rev 3 matchup chips: TeamChip primitive + board-row team context
Design Rev 3 anchors every matchup/context abbr with a 10-12px team tile. New
reusable TeamChip renders the real TeamLogo (licensed ESPN logo where it
resolves, team-colored monogram otherwise — the resolver is already built) at
that size + the abbr, sitting inside the row so it inherits the ranked opacity
ramp. First placement: StatStrip's player/team context (name → team-chip →
archetype). ROW-GRAMMAR identity-run test updated to the chip marker.

Remaining Rev 3 placements to thread TeamChip into (reusable, mechanical):
parlay legs, grade-shift header, pitcher "vs", /u recent-settled, other
matchup context lines. Game-card headers already carry TeamLogo (TeamLink).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:27:47 -04:00
builtbykev 771d8b0ba4 Rev 3 glyphs: wire the 9 classifier-legacy marks (74→83), all backend archetypes now render real marks
Design Rev 3 drew the 9 legacy classifier marks (BRUSH #E0B84A, WHIFF #E86A6A,
CONNECTOR #9AB0C4, DISTRIBUTOR #7AB8D8, FASTBREAK #4AA0E8, FLEX #A08AC8,
HYBRID #C88AB0, SWITCH #C0B08A, SWITCHBOARD #90A0E8). Wired each to its own
Design mark + color (front lib/archetypes.js + backend archetypeService.js,
color-synced). These were the 9 NO-MARK backend keys — now none are on a
generic placeholder. They're classifier-side fallback renders (never
user-facing archetype names, per MANIFEST).

Re-imported Rev 3 package over specs/design-reference/ (83 glyph SVGs + MANIFEST
regenerated from the authoritative glyphDefs(); HANDOFF Rev 3 note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:23:59 -04:00
builtbykev ebaab007d4 M3 palette consistency: orphaned B-blue monogram + old text-0 in OG billboards
Token alignment (M3.1) orphaned a few bare literals: PlayerAvatar's team-less
monogram accent still fell back to the retired B-blue #4A9EFF → Design neutral
#B8BCC8; the player/u OG + portrait billboards painted old text-0 #E8E8F0 →
Design #F0F0F0 (satori canvas needs literals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:09:50 -04:00
builtbykev 1558710458 M3.3a book wordmark — BookComparison renders BookChip, not the raw key
The only surface still showing a raw book key: BookComparison rendered
{b.book} capitalized ('Betmgm'). Now uses the entity-layer BookChip (branded
wordmark + name), matching ledger/GameCard. No lowercase/raw book text anywhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:03:34 -04:00
builtbykev 77d8fd658d M3.6 glyph lift — 29 archetypes now render Design's real 74-mark set
Replaced the generic reused shapes (triangle/star/plus/bolt…) with Design's
real per-archetype marks from the authoritative glyphDefs() (HANDOFF), and
aligned each archetype's color to Design's deduped palette — frontend
lib/archetypes.js + backend archetypeService.js kept in color-sync (the
cross-file test iterates the backend set). Badge test color expectations
updated to Design (BOMBER #FF9F45, CONDUCTOR #6C8CFF, ALPHA #7C5CFF,
FORTRESS #4C6FA5, …).

Scope + honesty:
- 29 non-combat archetypes wired to real marks + Design colors.
- COMBAT namespace (STRIKER/GRAPPLER/PRESSURE/COUNTER/FINISHER/GRINDER) left
  untouched — it uses unicode CHAR glyphs + its own pinned colors + test
  (FINISHER deliberately doesn't collide with the soccer FINISHER). Combat
  could adopt Design's SVG marks in a follow-up.
- 39 Design marks are INERT (no classify() producer yet) — the 74 SVGs live in
  specs/design-reference/assets/glyphs/; they light up when classify() expands.
- 9 backend archetypes have NO Design mark (BRUSH/CONNECTOR/DISTRIBUTOR/
  FASTBREAK/FLEX/HYBRID/SWITCH/SWITCHBOARD/WHIFF) — kept on their generic glyph,
  flagged for Design.

VISUALLY UNVERIFIED at 390px/desktop — archetype marks + colors on the audit list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:01:48 -04:00
builtbykev c3d469e964 M3.1 follow-up: ShareCard grade-B billboard → Design white; drop stale comment hexes
The share/OG card (a billboard) still painted grade B blue (#4A9EFF) on canvas;
Design's B is neutral-bright white. Canvas needs a literal, so #F0F0F0 direct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:23:56 -04:00
builtbykev e0a26a8055 M3.1 token alignment — the palette to Design's exact package values
Reconcile finding: the live tokens had DRIFTED from Design's package (HANDOFF
"Tokens (exact)"). Per the standing conflict rule (Design specified exact hexes
→ rejected the drift → Design wins), aligned the whole palette:
- Surfaces: --bg-1 #0E0E16→#0E0E14, --bg-2 #15151F→#14141E; added --bg-deep
  #0A0A10 + --hairline #101018 (Design's ramp).
- Text: --text-0 #E8E8F0→#F0F0F0, --text-1 #7A7A8E→#B8BCC8 (Design's secondary
  is far brighter), --text-2 #4A4A5E→#707080, added --text-3 #4a4a58 micro.
- Borders: #1E1E2E→#1E1E2A, #2A2A3E→#2A2A38.
- GRADES (the big one): B blue #4A9EFF → neutral-bright WHITE #F0F0F0; C amber
  #FFB347 → muted GREY #B8BCC8; D #FF5252 → #FF4757. The old blue/amber actually
  violated DESIGN-SPEC v2's OWN "B neutral-bright, C muted" — this fixes a
  long-standing drift, confirmed by Design's package. Amber stays its own token
  (--amber); --warning decoupled to --amber; --miss → #FF4757.
- vyndrTokens.js GRADE_HEX mirror + the design-system test updated to match.

Big VISUAL change (grade color language), VISUALLY UNVERIFIED at 390px/desktop
— on the Chrome-audit list. Every surface inherits it, so it lands first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:19:30 -04:00
builtbykev 63511485ed Design train: unpack the complete authoritative package into design-reference
Supersedes the prior partial import. From claude.ai/design project 370ba6df:
- HANDOFF.md (the entry point — exact tokens, glyph library, liftable
  behaviors, laws)
- vyndr-mobile.html (19 mobile screens — closes all 10 previously-uncovered
  surfaces: landing/pricing/streaks/ledger×2/empty/scan/player/team/explore +
  combat/pitcher/archetypes/correlation), vyndr-system.html (desktop terminal),
  support.js
- assets/glyphs/ — all 74 archetype marks as real SVGs + MANIFEST, GENERATED
  from the authoritative glyphDefs() in the desktop file (currentColor, 24-grid,
  duotone) rather than 74 fetches. These replace the generic star/plus
  placeholders (the archetype family was never rendering as a distinct 74-mark
  system).
- removed my interim MOBILE-SPEC.md distillation (the real 19-screen file +
  HANDOFF supersede it).

Landing (desktop marketing) captured in-context — it's M2/M3-desktop scope;
raw file stays in the design project until that wave.

RECONCILE + build (token alignment, glyph lift, M1b surfaces, M3) follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:52:29 -04:00
builtbykev 7c7ab24be5 M1b: mobile app-bar clock — Design's resting clock + STALE reaction (Hybrid)
Design's mobile app bar shows a wall clock, no SYNC/STALE readout. Building it
literally would drop the Ship-A staleness signal on the device most users are
on. Kev's ruling: Hybrid — the wall clock is the RESTING state (the stillness),
STALE/amber is the REACTION (the punctuation), driven by the SAME real signal
as desktop (refreshed_at vs expected_interval_s, thresholds 1.5×/3×). Never
silently stale on mobile. Design drew the happy path; we keep the failure state.

- web/src/lib/freshness.js — extracted the freshness tier as ONE shared source
  (CommonJS, unit-tested); desktop HeartbeatBar + mobile clock both key off it,
  so mobile can't silently disagree with desktop about staleness.
- LiveLayer: <768px hides SIGNAL LIVE + EKG + graded + the SYNC label and shows
  a single right-aligned clock — ticking wall clock when calm, amber SYNC / red
  STALE when the tier reacts. Resting dot is static (the clock is the pulse).
- Test-lock: freshness.test.js (tier thresholds, absent≠false-stale, no negative
  age) + vyndrParityQA (mobile clock wired, one shared freshness source).

Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px. NEXT shell
increments (still M1b): merge the clock into the logo row (Design's single-row
app bar), the breadth strip (EDGES/AVG CLV/GAMES — replaces the graded count
mobile lost here), and relocate the wire to the bottom of the board.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:26:00 -04:00
builtbykev c4d4617fab Design train: capture Design's drawn mobile + reconcile M1a to it
Design now provides mobile designs (Vyndr Mobile.dc.html, 5 screens at 390×844).
Captured as specs/design-reference/MOBILE-SPEC.md — the buildable distillation
(the raw dc-runtime HTML needs React to render; the design project holds the
verbatim source). I'm now IMPLEMENTING Design's mobile, not inferring it.

RECONCILE of the already-shipped M1a against Design's mobile:
- EKG hidden <768px — Design AGREES (mobile app bar carries no EKG). KEPT.
- .vbtn 44px tap target — Design AGREES (buttons are 44/46px). KEPT.
- .m-hero generic clamp — CONFLICT. Design uses CONTEXT-SPECIFIC hero sizes
  (74px grade tier / 40px live tier / 24px /u stats), not one clamp, and
  nothing consumed the class. REMOVED; the specific sizes land per-surface in M1b.
- Mobile HEADER shape — Design OVERRIDES: mobile is an app bar (logo + sync
  clock) + a breadth strip + a bottom wire, NOT the collapsed heartbeat bar.
  The EKG-drop is a compatible interim step; the full app-bar recomposition is
  M1b (the board/shell surface). Flagged in the CSS + MOBILE-SPEC.

Built to Design's mobile spec, VISUALLY UNVERIFIED at 390px (Chrome audit is eyes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:03:12 -04:00
builtbykev b1138eac5e STATE.md: design completion train started — mockup imported + M1a shipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:13:04 -04:00
builtbykev a10227ee5a Design completion train — import authoritative mockup + M1a mobile foundation
SETUP (required first step): overwrote specs/design-reference/ with the CURRENT
authoritative mockup ("Vyndr System.dc.html" from claude.ai/design, the version
with the combat card / pitcher identity / live grade-shift / correlation builder
/ FREE|PRO pricing / TRANSMISSION QUIET). 12 surfaces. Confirmed it has NO media
queries — desktop-only, so the 390px expression is a deliberate design decision,
not a shrink.

M1a — mobile foundation (built to spec, VISUALLY UNVERIFIED at 390px; WSL2↔Chrome
unreachable, the Chrome audit is the eyes):
- Header-zone collapse (the concrete audit finding: ticker + SIGNAL LIVE + STALE
  stacking in ~110px). The decorative EKG is dropped <768px so the heartbeat
  reads as ONE clean status line; signal pulse + ticker are the single animated
  element (DESIGN-SPEC §4). LiveLayer gains .heartbeat-bar / .hb-ekg hooks.
- Primary CTA (.vbtn) meets the 44px touch target on mobile; dense `small`
  buttons opt out (density is a feature).
- .m-hero mono-hero clamp for the one-figure-per-card grammar at 390px.
- M4 test-lock: vyndrParityQA asserts the header collapse, tap target, overflow
  containment, and the honest "UNVERIFIED at 390px" label are all in source.

Per-surface stacking (M1b), billboards (M2), desktop parity (M3) continue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 05:09:14 -04:00
builtbykev 520548d3f7 STATE.md: heartbeat + per-sport cadence + quota-guard shipped (2026-07-15)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:11:44 -04:00
builtbykev 2d413cfe1e Quota guard: close the silent odds-api drain + reserve floor for MLB
Diagnosis (why 500/500 went unpaged): the only regular odds-api burner was
futuresService, which called axios DIRECTLY — bypassing the gateway, so it
never hit recordCall (the ONE place the WARN/BLOCK pager fires) and never
respected the 95% block. It only syncFromHeaders, which updated the counter's
number SILENTLY. oddsService (which does go through the gateway) only touches
odds-api when PropLine fails, so recordCall for odds-api effectively never ran.
Result: the counter could reach 100% with neither pager firing.

Fixes (a silent drain is now impossible, not just guarded):
- futuresService routes through gateway.fetch('odds-api', …) → counted, blocked
  at 95%, and reserve-gated. Closes the raw-axios bypass.
- Reserve floor in the gateway: a DISCRETIONARY call (futures/soccer) passes
  reserve=ODDS_API_RESERVE (default 50) and is refused while remaining <= reserve.
  The ESSENTIAL MLB prop-backup passes no reserve and may spend to the 95% block.
  → a futures/soccer drain can NEVER starve MLB's backup path.
- quotaTracker.syncFromHeaders (the AUTHORITATIVE number) now fires the same
  once-per-period WARN/BLOCK alert on a crossing — extracted fireThresholdAlert
  shared with recordCall. The header-only drain now pages.
- POST /api/internal/quota/test-alert (internal-key) test-fires the pager
  end-to-end so ntfy delivery is verifiable on demand.

Also (reality-corrected cadence): WNBA restored to the full grid. 2026-07-15
had two AFTERNOON WNBA games finished before the 22 UTC slot — 14 UTC (10am ET)
is the only slot early enough for a 1pm ET game's props, and on PropLine the
extra slots cost a rounding error. Soccer stays the only trimmed sport (the
real odds-api discipline). Assumption corrected by observed data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:07:14 -04:00
builtbykev 4cd933d83e Job 1: per-sport snapshot cadence (config, not baseball's rhythm for all)
Every ACTIVE sport was graded at all five MLB slots (14/19/22/1/3 UTC). Sports
post lines on different clocks, so that inheritance was wasteful both ways:
WNBA props aren't posted at 14:00 UTC (10am ET) → that slot always graded 0
(the audit's "wnba:0"); soccer odds come from the 500/MONTH odds-api key, so
five slots/day is a third of the budget for 1-2 matches.

New src/config/sportCadence.js is the single source of truth (config-over-
constants). Mapped from reality + quota headroom (PropLine 9k/day abundant,
odds-api 500/mo scarce):
  mlb    14/19/22/1/3  intraday   (full grid — games+props all day)
  nba    14/19/22/1/3  intraday   (in-season fits; off-season self-skips empty)
  wnba   19/22/1       intraday   (afternoon→evening ET; drops the 14/3 waste)
  soccer 14/19         NO intraday (WC live; 2 lean odds-api reads, key-protected)

The scheduler still fires at HOURS_UTC and the missed-cron watchdog still
references MLB (which runs every grid hour) — each slot now grades only
sportsForHour(h), and only intradaySports() get the 20-min refresh. Every
sport's hours are kept a subset of the firing grid (a boot-time guard + a test
warn if that's ever violated). Retune a sport by editing one table row.

Adaptive, not constant: near-zero when a sport is quiet, protecting the scarce
odds-api quota from being drained by noon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:17:55 -04:00
builtbykev 7712f0a442 Heartbeat honesty: SYNC badge reads refreshed_at, not grade-lock updated_at
The "SIGNAL LIVE vs STALE 8h" contradiction was a field mismatch, not a dead
pipeline. updated_at is the grade-LOCK time (advances only on a full snapshot,
5×/day — grades never change in-game, so it is intentionally stable). The
SYNC badge measured the 20-min intraday cadence (expected_interval_s=1200)
against that 5×/day field → structurally guaranteed STALE between slots even
when intraday refreshes lines perfectly.

- snapshotService: full snapshot now seeds refreshed_at at lock time
- intradayRefresh already bumps refreshed_at every ~20 min (unchanged)
- /api/snapshot/summary + GET /:sport now expose refreshed_at (was written to
  Redis but never serialized → no public liveness signal existed)
- LiveLayer SYNC badge measures freshness from refreshed_at (fallback updated_at)

Exposing refreshed_at also gives a public heartbeat probe: it advances every
intraday slot, so pipeline liveness is verifiable without container logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:09:18 -04:00
builtbykev 4d7fe09360 STATE.md: truth-everywhere train shipped (W0/1/2 + resolver), futures gap noted
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:32:31 -04:00
builtbykev 45aa8caec9 Deploy: WNBA/NBA resolver hardening — broad grade coverage
Roster-index primary resolver (206 WNBA / 544 NBA) + season-param gamelog retry
for dual-league players (the real Collier-miss root cause: empty gamelog until
?season supplied). Collier/Brionna Jones now resolve 20 rows. MLB untouched. +9 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:27:43 -04:00
builtbykev bbfce05b94 Resolver hardening: ESPN team-roster index as primary NBA/WNBA name→id source
Wave 0 shipped NBA/WNBA grading off ESPN per-athlete gamelogs, but name→id
resolution went only through the v2 /search endpoint, which is unreliable at
the edges. Live probing surfaced the real coverage gap: search actually
resolves the right id for most names, but dual-league athletes (WNBA + NCAA —
e.g. Napheesa Collier, Brionna Jones) get a filters-only gamelog until a
`?season=` is supplied, so they silently returned insufficient_data despite a
full season of games.

Two fixes:

1. buildAthleteRosterIndex(sport) — aggregates every team roster for nba/wnba
   into a complete { nameKey → {id, displayName, teamId} } map (canonical
   accent-folded keys via playerName.nameKey). Bounded concurrency (6) over the
   ~15-30 team fetches, Redis `espnroster:{sport}` (24h) + in-memory mirror,
   fully defensive (a failing team is skipped → partial index, never throws;
   grouped OR flat athletes[] shapes handled; non-numeric ids dropped). This is
   now the PRIMARY resolver in resolveAthleteId/getPlayerGameLog; the v2 search
   stays as a backstop on a roster miss. A unique roster hit wins (S59 doctrine)
   — a missing name beats guessing another player's id.

2. getPlayerGameLog retries the gamelog with candidate seasons (current +
   previous calendar year) ONLY when the first parse comes back empty
   (filters-only), unlocking the dual-league athletes. The common path is
   untouched.

MLB path (statsapi) unchanged; settlement/snapshot/frontend untouched.

Live probe: WNBA roster index = 206 players (Collier id 3917450 / Lynx team 8,
Brionna Jones id 3058895 present); NBA index = 544. Collier now resolves
end-to-end with 20 gamelog rows (was NOT FOUND); Brionna Jones likewise; all
previously-working players (A'ja Wilson, Ionescu, Clark, Stewart, Plum) still
resolve. NBA hyphen names (Gilgeous-Alexander) resolve via nameKey folding.

Tests: tests/unit/espnRosterIndex.test.js (9, fail-then-pass on base adapter).
Full backend suite 3183 green; web build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:21:59 -04:00
builtbykev c2cfb7c508 Deploy: never-dark offseason hub (Wave 2) — ESPN news wire + futures board
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:36:38 -04:00
builtbykev fe5a495e90 Merge Wave 2B: never-dark offseason hub UI (NewsWire + FuturesBoard on /explore)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:35:27 -04:00
builtbykev e75b81e451 Merge Wave 2A: offseason data feeds (ESPN news wire + quota-disciplined futures)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:35:27 -04:00
builtbykev f0752b804b Wave 2A: offseason data feeds — news wire + quota-disciplined futures
FREE ESPN news wire + championship-winner futures for the never-dark
offseason hub. Both graceful/empty, never fabricate a market value.

- newsService (mirrors injuryService): per-sport ESPN /news FEEDS, pure
  parseNews → { sport, items:[{id,headline,description,published,type,
  athlete?{name,key},team?,href}] }; athlete/team from categories[] only
  (absent when not present). Cache 15m, injectable, offline-tested.
- oddsNormalizer.normalizeOutrights: NEW branch — outrights outcomes are
  {name,price} with no point, so normalizeProps drops them; keeps them with
  best-price-across-allowed-books per selection. + americanToDecimal.
- oddsService.FUTURES_KEYS: separate map (mlb/nba/wnba championship winner),
  OUT of the daily SPORT_KEYS/snapshot budget.
- futuresService: getFutures(sport,deps) → { sport, updated_at, markets:
  [{key,title,selections:[{name,price,prevPrice?,move?}]}] }. One outrights
  call per 12h TTL (quota-disciplined), FUTURES_ENABLED gate. Price-move
  (shortening/drifting/flat) mirrors computeLineDeltas SHAPE on odds not
  line; prev prices persisted inside the futures:{sport} value (no new key).
  linkNewsToMoves pure causal-tie helper.
- Routes /api/news/:sport + /api/futures/:sport (registered) + Next proxies.
- Tests: newsService, futuresService, oddsNormalizerOutrights (fail→pass,
  no network). Full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:33:26 -04:00
builtbykev 1664e1b3d5 Wave 2B: Offseason never-dark hub — NewsWire + FuturesBoard on /explore
Extend /explore (ExploreHub) into the 365-day never-dark hub. Two new
self-hiding sections feed REAL always-available data into the offseason:

- NewsWire (components/vyndr/NewsWire.tsx): real ESPN headlines from
  /api/news/:sport (newest-first, mono timestamps, type chips, player/team
  links) + real injuries from /api/schedule/:sport/injuries (OUT/GTD chips,
  token colors). Reuses the retired TerminalTemplates INJURY_WIRE layout but
  never routes its sample constants. Self-hides when both feeds are empty.
- FuturesBoard (components/vyndr/FuturesBoard.tsx): real futures from
  /api/futures/:sport — championship/win-total/award markets, mono tabular
  prices + movement colored by the contract (shortening=green / drifting=amber
  / flat=dim, NEVER red; move shown ONLY when the backend supplies one).
  Carries the honest "TRACKED · NOT GRADED" label — no fabricated grades on
  futures. Self-hides when markets:[].
- ExploreHub is offseason-aware (via emptyState OFF_SEASON month check): the
  hub LEADS with futures + wire when the board is dark, COMPLEMENTS the live
  board in-season. Sport selector kept; each section self-hides independently.
- Testable pure helpers: lib/futuresMove.js (move→color, never red) +
  lib/newsFormat.js (timeAgo mono-stamp, ESPN type labels).

Contracts consumed (Wave 2A owns the proxy/service files); code self-hides on
fetch failure if a proxy isn't present yet.

Tests: tests/unit/newsWire.test.js + futuresBoard.test.js (26 new). Full suite
259 suites / 3144 green; web build exit 0. vyndrParityQA stays green
(mono data, no glitch on data surfaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:27:19 -04:00
builtbykev 31dc3799ee Deploy: NBA/WNBA settlement (Wave 1) — grade->settle->learn everywhere
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:05:50 -04:00
builtbykev a4b6255bed Merge Wave 1: NBA/WNBA settlement (grade→settle→learn everywhere)
defaultGetPlayerStats routes nba/wnba to Wave-0's ESPN getPlayerGameLog (both
outcomeService + ledgerService); separate NBA_BOX_KEY/NBA_COMBO settle maps;
final-honesty guard (never settle an in-progress game); ET-date normalization;
opsWatch finals-gated zero-settle alarm + per-sport boot announce. accuracy:{sport}
+ by_tier light up automatically. +21 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:40:24 -04:00
builtbykev 873a92931c Wave 1: NBA/WNBA settlement — grades settle vs free ESPN game logs
Unblocks the self-learning loop for basketball. Once an NBA/WNBA grade
exists (Wave 0), it now settles against the FREE ESPN per-game log
(espnStatsAdapter.getPlayerGameLog) — the same {found, last10:[{date,stat}]}
contract MLB settlement already consumes. accuracy:{sport} + by_tier
calibration + the Wave-3 TierRecord light up automatically.

- outcomeService/ledgerService: defaultGetPlayerStats routes nba/wnba to
  espnStatsAdapter.getPlayerGameLog; MLB stays on mlbStatsAdapter.
- outcomeService: sport-aware statValue + a SEPARATE NBA_BOX_KEY/NBA_COMBO
  map (S11 three-map-split kept — never merged with MLB_LOG_FIELD). Combos
  (pts_reb_ast, reb_ast, stl_blk, …) sum components; a missing component
  never fabricates a total.
- logRowOnDate: ESPN gamelog rows carry a FULL ISO timestamp (a late tip
  rolls past UTC midnight), so basketball date-matches on UTC OR ET date;
  MLB keeps exact YYYY-MM-DD compare. Outcome `date` is normalized to the
  ET calendar day so the accuracy window filter + idempotency key behave
  identically across sports.
- Final-honesty guard: never settle a basketball row whose ET date is
  today (an in-progress partial box). MLB is final-only + settles same-day,
  so the guard is scoped to basketball. The ledger path is already guarded
  (.lt('game_date', today)) for all sports.
- opsWatch: nba/wnba added to SETTLEABLE_SPORTS; zeroSettleAlarm gates them
  behind a real-finals probe (finalsBySport) so an offseason/off-day's
  stale pendings never false-page "settled 0". snapshotScheduler counts
  yesterday's ESPN state==='post' events and feeds the map; MLB unchanged.
- snapshotScheduler: boot announce per settleable sport
  ([settle:mlb] [settle:nba] [settle:wnba]). Thrown-error paging already
  covers the new sports (settleAll* loop every sport).

Tests: tests/unit/nbaSettlement.test.js (16) — WNBA hit/miss/push, combo
pra, idempotent re-run, unplayed/today game does NOT settle, accuracy:wnba
+ byGrade + by_tier populate, ledger WNBA settle. opsWatch (+5) — finals
off-day no page, finals present DOES page. MLB suites unregressed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:30:50 -04:00
builtbykev 4c8d339987 Deploy: NBA/WNBA grade unlock (Wave 0) + headshot coverage + truth-train map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:55:33 -04:00
builtbykev cce99709c9 Merge Wave 0: NBA/WNBA grade unlock (free ESPN gamelogs)
featureCache.gameLogFeatures falls back to espnStatsAdapter.getPlayerGameLog
(free ESPN per-game logs) when the offline Python source returns null → NBA/WNBA
produce l5/l20 → props GRADE instead of refusing. +18 tests, proof test locks
the refuse→grade transition. MLB unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:47:06 -04:00
builtbykev 287c1c047a Wave 0: NBA/WNBA grade unlock — free ESPN per-athlete gamelog source
The Python nba_api service (gameLogService) is offline in prod, so
featureCache's non-MLB branch produced no l5/l20 averages →
projectionFor returned null → the ENTIRE NBA/WNBA slate refused
(insufficient_data). Only MLB actually graded.

Fix (free, no-auth, verified live):
- espnStatsAdapter.getPlayerGameLog(name, sport) — resolves name→ESPN
  numeric athlete id via the v2 search (the v3 /search now returns
  count:0; the v2 uid carries a:<id>, defaultLeagueSlug disambiguates
  league), fetches the per-athlete gamelog, and parses per-game rows
  keyed by VYNDR stat names (points/rebounds/assists/threes/steals/
  blocks/turnovers + computed pra). Columns are indexed by the
  response's own names[] array (NBA and WNBA orders DIFFER), never
  positionally. Most-recent first, defensive (null on unrecognized
  shape, never throws), cached (espngamelog:{sport}:{id} 4h + memory).
- featureCache.gameLogFeatures — falls back to the ESPN gamelog for
  nba/wnba when the Python source returns null/empty, producing
  l5/l10/l20 + rest_days + minutes_per_game via a new local
  NBA_LOG_FIELD map + pure nbaGameLogFeatures (S11 three-map-split:
  separate from MLB_LOG_FIELD).

Grade gates already whitelist all 8 NBA/WNBA stat types in both Node
paths (analyze.js + scan.js); no gate change needed.

Tests (hermetic, no network): espnGameLog.test.js (parser/resolver/
adapter) + featureCacheNba.test.js (the UNLOCK proof — empty features
refuse, ESPN-derived features grade). 3098 tests green; next build
exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:44:57 -04:00
builtbykev 153ff21c11 Truth-train decisions locked: futures (quota-disciplined), soccer deferred, ESPN gamelog verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:23:25 -04:00
builtbykev 4a7046a37e Step 0 MAP: truth-everywhere + offseason hub — grading is the real first domino
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:18:02 -04:00
builtbykev 392cf3ede5 Cross-sport headshot coverage hardening
Harvest ESPN athlete id + direct headshot href from the game-summary feeds
already called (independent of the flaky Python stats path) → reliable
NBA/WNBA/NFL/NHL headshots when in-season; soccer stays honest monogram
(no free id). MLB unchanged (zero extra I/O).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:15:00 -04:00
builtbykev fceb3707b5 Wave 2B: reliable cross-sport headshots via ESPN athlete index
The NBA/WNBA espnId was captured only from espnStatsAdapter (the offline-
Python fallback), unreliable in prod. Add espnAthleteIndex — a pure,
defensive harvester that builds { nameKey -> {espnId, headshotHref} } from
the ESPN schedule->summary/boxscore/leaders/injuries/roster feeds the
pipeline already calls (free, bounded mapLimit, cached, MLB->{}).

snapshotService now fills any player the primary stats-resolve left without
an espnId from this index, and stores a DIRECT headshotHref as headshotUrl
on the enriched grade (the exact URL, never 404s on a constructed path).
Threaded headshotUrl through slateAdapter.buildPlayerStripsFromProps ->
GameCard -> StatStrip -> PlayerAvatar/getHeadshotUrl (direct href wins over
the constructed one). MLB's MLBAM path is untouched. Soccer resolves only
via a direct href; absent -> honest monogram (API_FOOTBALL_KEY remains the
reliable soccer path, unwired).

getGameSummary now also passes through ESPN `rosters` (pre-game lineups
carry id + headshot). Everything graceful: any miss -> absent -> monogram.

Tests: tests/unit/espnHeadshotIndex.test.js (11) — fixture->index, snapshot
merge fallback, direct-href-wins, soccer honest monogram, malformed/cyclic
parse never throws. Full suite 3080 green; web next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:31 -04:00
builtbykev 3b1aa9f265 Merge the wiring & data train (6 waves) into main
Real entity assets (headshots/logos/book wordmarks), record-by-grade-tier,
outlook mode, market-breadth, Parlay Lab, grade-shift timeline, /u house-mode
public record, pitcher arsenal (Savant), combat intelligence v1 (MMA), and
three trust-bug fixes. 253 suites / 3069 tests green, next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:42:20 -04:00
builtbykev 9ed5bd818b STATE.md: wiring & data train complete (6 waves), ready to merge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:02:52 -04:00
builtbykev b06d825a02 Merge Wave 6 (wiring/data): combat intelligence v1 (MMA)
ESPN-MMA fight cards + tale-of-the-tape + style-blend archetypes + odds-api
ML/round-totals + style-edge verdict. Honest free v1 — no settled grades,
method/round data-limited, no fighter photos, no scraping dep. +31 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:01:00 -04:00
builtbykev 54fa5853f5 Wave 6: Combat Intelligence Layer (honest free v1)
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) <noreply@anthropic.com>
2026-07-13 16:58:22 -04:00
builtbykev 016758e014 Combat intelligence spec (Wave 6): honest free v1, pinned archetype registry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:07:33 -04:00
builtbykev 9c85f25aea Merge Wave 5B (wiring/data): pitcher arsenal via Baseball Savant (D4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:04:06 -04:00