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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>