Session 43: Data pipeline + audit fixes + depth chart foundation (2045 tests)

P0 fixes + wiring real data into the S42 Player Intelligence architecture.

- P0 dropdown z-index: the nav's backdrop-filter stacking context let the
  Ticker/HeartbeatBar paint over the avatar/More dropdowns and eat clicks.
  nav now position:relative zIndex:2; menus zIndex:100. Avatar Settings ->
  /settings.
- Real MLB stats: mlbStatsAdapter.searchPlayer + getPlayerStats (name->id->
  season+gamelog). playerIntelService.resolvePlayerStats normalizes into the
  archetype classifier; getPlayerIntel returns found:true + real season +
  archetype classified from real stats. NBA via nbaStatsClient (degrades).
- Game cards: slateAdapter.groupPropsByPlayer (playerStrips, name once) +
  mapPitchers (MLB probables), folded into mapScheduleToGameCards. Legacy
  GameCard line grid renders BookChip (brand colors) not grey text.
- Grade card intel: analyzeViaEngine1.buildIntelFields computes stat-context +
  form/usage/matchup/rest from the existing feature vector (zero extra I/O);
  gradeAdapter lights up the card sections. Archetype deferred (needs season
  line at grade time).
- Depth chart foundation: depthChartService (getLineup/getDepthChart/
  getCascadeProjection) + /api/stats/lineup|depth|cascade, graceful + injectable.
- Mobile: player hero name overflow-wrap + 24px on <=640px (was clipping).

Backend 2011 -> 2045 tests (+34), 163 suites. Web build clean (exit 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-06-18 15:38:06 -04:00
parent 8bc79f3c38
commit 80683e71b4
19 changed files with 940 additions and 18 deletions
+69 -3
View File
@@ -4,9 +4,75 @@
2026-06-18 2026-06-18
## Current Phase ## Current Phase
SHIP BUILD v42.0 — Player Intelligence System (archetypes, stat strips, player SHIP BUILD v43.0 — Data pipeline wiring + P0 audit fixes + depth chart
profile, enhanced cards, settings, stats explorer). Built from the Claude Design foundation. The Session-42 Player Intelligence architecture now flows REAL data.
"VYNDR Player Intelligence" bundle.
## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES
Post-S42 Chrome audit: architecture renders but no real data flowed (every
player `found:false`, leaders empty, grade intel hidden) + a P0 dropdown bug.
Backend 2011 → **2045 tests** (+34), 163 suites. Web build clean (exit 0).
### Phase 1 — P0 bug fixes
- **Avatar/More dropdown z-index** — the `<nav>` has `backdrop-filter` (a
stacking context); `<Ticker>` + `<HeartbeatBar>` render after it as siblings
and painted OVER the dropdowns (which overflow below the 60px bar), eating
clicks. Fix: `position:relative; zIndex:2` on the nav floats it (and its
menus) above those bars; menus also carry `zIndex:100`. Avatar "Settings" →
`/settings` (was `/settings/security`). Explore was already in MORE (S42) —
the audit "missing" was a deploy cache.
### Phase 2 — real player stats
- **`mlbStatsAdapter`** gained `searchPlayer(name)` (season player-list lookup,
cached 24h) + `getPlayerStats(name)` (resolve id → season + game log, picks
pitching/hitting by position). The adapter only had id-keyed methods before.
- **`playerIntelService.resolvePlayerStats(name, sport)`** normalizes the raw
MLB stat object → classifier input + display season rows + last-10 log.
`getPlayerIntel` now classifies the archetype from REAL stats and returns
`found:true` with real season data. Aaron Judge → POWER PULL, not the
empty-stats fallback. NBA/WNBA path wired to `nbaStatsClient` (degrades
quietly — the Python nba_api service is usually offline in prod). All
adapters injectable for tests; everything still degrades gracefully.
- **Leaders**: `/api/stats/leaders` + the explore page already share the
`grades:{sport}` cache source — no mismatch to fix. Emptiness is data
population (the slate must be graded first); the snapshot pipeline (Session
44) keeps it warm.
### Phase 3 — game card enhancements
- `slateAdapter` gained `groupPropsByPlayer` (→ `playerStrips`, name once) +
`mapPitchers` (MLB probables → GameCard `pitchers`); both folded into
`mapScheduleToGameCards` output (vyndr/GameCard consumes them; legacy ignores
the extras). Legacy `GameCard` line grid now renders `BookChip` (brand
colors) instead of plain grey book text (the audit's complaint).
### Phase 4 — grade card intelligence
- `analyzeViaEngine1` attaches stat-context + VYNDR-intelligence fields
(`season_avg`/`last10_avg`/`form`/`usage`/`matchup_grade`/`rest`) computed
from the EXISTING feature vector — zero extra I/O. `gradeAdapter` maps them in,
so the card's STAT CONTEXT + VYNDR INTELLIGENCE sections light up. Archetype
is intentionally NOT set here (the per-prop feature vector lacks a multi-stat
season line; the archetype strip stays hidden until Session 44 feeds it).
### Phase 5 — depth chart foundation
- **`src/services/depthChartService.js`**: `getLineup` (MLB probable starter
from the schedule), `getDepthChart` (positions from an injected roster),
`getCascadeProjection` (usage redistribution weighted by archetype — sponges
heaviest). All graceful + injectable. Endpoints: `/api/stats/lineup/:team`,
`/depth/:team`, `/cascade/:player`. (Next proxies + UI = Session 45.)
### Phase 6 — mobile + cosmetics
- Player-profile hero name was clipping ("Wembanyam") at 390px — added
`overflow-wrap:anywhere` + a mobile rule (`.player-hero-name` 24px). Font CDN
already gone since S41 (next/font).
### Deferred (honest scope)
- Full live-slate swap to vyndr/GameCard (needs inline grading ported) — the
data layer is now ready (`playerStrips`/`pitchers`).
- Per-player archetype badges on slate cards + grade-card archetype strip — need
per-player season lines at grade time (Session 44 snapshot pipeline).
- Depth chart UI + lineup/minutes projections (Session 45).
## Session 42 (2026-06-18) — SHIPPED ✅ PLAYER INTELLIGENCE SYSTEM
## Session 42 (2026-06-18) — SHIPPED ✅ PLAYER INTELLIGENCE SYSTEM ## Session 42 (2026-06-18) — SHIPPED ✅ PLAYER INTELLIGENCE SYSTEM
+31
View File
@@ -355,6 +355,37 @@ Built from the Claude Design "VYNDR Player Intelligence" bundle.
- **Components added to the barrel**: ArchetypeBadge, ArchetypeBlend, StatStrip, - **Components added to the barrel**: ArchetypeBadge, ArchetypeBlend, StatStrip,
BookChip (`@/components/vyndr`). Book brand map = `web/src/lib/books.js`. BookChip (`@/components/vyndr`). Book brand map = `web/src/lib/books.js`.
## Data Pipeline Wiring (Session 43 — non-obvious)
- **Dropdown z-index P0** — the `<nav>` uses `backdrop-filter`, which creates a
stacking context. The living-layer bars (`Ticker`, `HeartbeatBar`) render as
siblings AFTER it, so dropdowns that overflow below the 60px nav got covered.
The nav carries `position:relative; zIndex:2` to float above them — don't
remove it, and keep dropdown menus at `zIndex:100`.
- **MLB real stats** — `mlbStatsAdapter` is id-keyed; Session 43 added
`searchPlayer(name)` (resolves via the cached season player list) +
`getPlayerStats(name)`. `playerIntelService.resolvePlayerStats(name, sport)`
normalizes the raw statsapi.mlb.com object into the archetype classifier's
input shape + display rows. `getPlayerIntel` classifies from REAL stats now.
Adapters are injectable (`opts.mlbAdapter`/`opts.resolveStats`) — tests never
hit the network. NBA/WNBA uses `nbaStatsClient` (Python service, usually
offline in prod → degrades to `found:false`, NOT an error).
- **Grade-card intel** — `analyzeViaEngine1.buildIntelFields(features)` computes
stat-context + form/usage/matchup/rest from the EXISTING feature vector (no
extra I/O) and `Object.assign`s them onto the legacy result. `gradeAdapter`
maps them into the card. Archetype is deliberately NOT attached at grade time
(the per-prop feature vector has no multi-stat season line) — that strip waits
for the Session-44 pipeline. Keep grade-time I/O at zero (slate grades in tight
loops; this is why archetype isn't fetched per-prop).
- **slateAdapter** now emits `playerStrips` (`groupPropsByPlayer`) + `pitchers`
(`mapPitchers`) on every card. The LIVE slate still uses the legacy
`components/GameCard` (which ignores those + renders `BookChip` in its line
grid); the full swap to `vyndr/GameCard` is still pending (needs inline grading
ported). `mapPitchers` returns undefined for non-MLB / no probables.
- **depthChartService** (`getLineup`/`getDepthChart`/`getCascadeProjection`) +
`/api/stats/lineup|depth|cascade` are the foundation; all graceful + injectable.
`matchesTeam` must guard empty names (`t.includes('')` is always true) — and
the schedule `find` checks `g.home`/`g.away`, not the game object.
## Active Skills ## Active Skills
- vyndr-voice (all user-facing output) - vyndr-voice (all user-facing output)
- prop-analysis (grading methodology) - prop-analysis (grading methodology)
+39
View File
@@ -3,6 +3,7 @@ const { getSupabaseServiceClient } = require('../utils/supabase');
const { getStatFilters } = require('../config/statFilters'); const { getStatFilters } = require('../config/statFilters');
const { createRateLimit } = require('../middleware/rateLimit'); const { createRateLimit } = require('../middleware/rateLimit');
const { getPlayerIntel, getLeaders } = require('../services/playerIntelService'); const { getPlayerIntel, getLeaders } = require('../services/playerIntelService');
const depthChart = require('../services/depthChartService');
const router = express.Router(); const router = express.Router();
@@ -165,4 +166,42 @@ router.get('/game/:id', intelLimit, async (req, res) => {
} }
}); });
// Depth chart / lineup / cascade (Session 43). Graceful — always 200 with a
// valid (possibly empty) shape so the UI never hard-fails.
// GET /lineup/:team?sport=mlb
router.get('/lineup/:team', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const lineup = await depthChart.getLineup(sport, req.params.team);
res.set(MISSION_HEADER).json({ sport, team: req.params.team, lineup });
} catch (err) {
console.error('[stats/lineup]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /depth/:team?sport=nba
router.get('/depth/:team', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const chart = await depthChart.getDepthChart(sport, req.params.team);
res.set(MISSION_HEADER).json(chart);
} catch (err) {
console.error('[stats/depth]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
// GET /cascade/:player?sport=nba&team=SA
router.get('/cascade/:player', intelLimit, async (req, res) => {
try {
const sport = String(req.query.sport || 'nba').toLowerCase();
const cascade = await depthChart.getCascadeProjection(sport, req.params.player, req.query.team);
res.set(MISSION_HEADER).json({ sport, player: req.params.player, cascade });
} catch (err) {
console.error('[stats/cascade]', err.message);
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
}
});
module.exports = router; module.exports = router;
+62 -1
View File
@@ -129,10 +129,71 @@ async function getBatterVsPitcher(batterId, pitcherId, group = 'hitting') {
return splits.length > 0 ? (splits[0].stat || null) : null; return splits.length > 0 ? (splits[0].stat || null) : null;
} }
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, '');
/**
* Resolve a player name → MLB person record (Session 43). Pulls the season
* player list (cached 24h — heavy but rarely changes) and matches by
* normalized full name. Returns { id, fullName, team, teamId, position } or
* null. Needed because every other adapter method keys on playerId.
*/
async function searchPlayer(name, season = DEFAULT_SEASON) {
const target = normName(name);
if (!target) 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 : [];
const hit = people.find((p) => normName(p.fullName) === target)
|| people.find((p) => normName(p.fullName).includes(target) && target.length >= 6);
if (!hit) return null;
return {
id: hit.id,
fullName: hit.fullName ?? name,
team: hit.currentTeam?.name ?? null,
teamId: hit.currentTeam?.id ?? null,
position: hit.primaryPosition?.abbreviation ?? null,
};
}
/**
* Name-keyed convenience: resolve the player, then fetch the season stat
* object for the right group (pitching for pitchers, hitting otherwise) plus a
* recent game log. Returns { found, id, name, team, position, group, 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) {
try {
const person = await searchPlayer(name, season);
if (!person) return { found: false };
const group = person.position === 'P' ? 'pitching' : 'hitting';
const [seasonStat, log] = await Promise.all([
getSeasonAverages(person.id, season, group),
getPlayerGameLog(person.id, season, group),
]);
if (!seasonStat) return { found: false, id: person.id, name: person.fullName, team: person.team, position: person.position, group };
return {
found: true,
id: person.id,
name: person.fullName,
team: person.team,
position: person.position,
group,
season: seasonStat,
last10: (log || []).slice(-10),
};
} catch (err) {
console.warn('[mlbStats] getPlayerStats failed:', name, err.message);
return { found: false };
}
}
module.exports = { module.exports = {
getScheduleWithPitchers, getScheduleWithPitchers,
getPlayerGameLog, getPlayerGameLog,
getSeasonAverages, getSeasonAverages,
getBatterVsPitcher, getBatterVsPitcher,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON }, searchPlayer,
getPlayerStats,
__internals: { BASE, TTL, extractSplits, ymd, DEFAULT_SEASON, normName },
}; };
+129
View File
@@ -0,0 +1,129 @@
/**
* depthChartService — lineup / depth-chart / cascade foundation (Session 43).
*
* Provides the data model + graceful aggregation for:
* - getLineup(sport, team) — tonight's projected lineup / starters
* - getDepthChart(sport, team) — starters + backups per position
* - getCascadeProjection(...) — "when X is OUT, teammate Y gets +delta"
*
* Sources (all best-effort, injectable for tests, never throws):
* - mlbStatsAdapter.getScheduleWithPitchers → probable pitchers (MLB)
* - scheduleService.getGameSummary → ESPN injuries / leaders
*
* This is the FOUNDATION: contracts + real data where it's freely available,
* graceful empty defaults elsewhere. The minutes/usage projection model and
* full batting orders arrive with the Session-44/45 pipelines.
*/
const norm = (s) => String(s == null ? '' : s).trim().toUpperCase();
/** Today's UTC date (YYYY-MM-DD). Injectable for deterministic tests. */
function todayISO(now) {
return (now || new Date()).toISOString().slice(0, 10);
}
/**
* Tonight's projected lineup for a team. MLB returns the probable starting
* pitcher (the one lineup slot the free schedule feed exposes); other sports
* fall back to the ESPN summary leaders when a game is found. Always returns an
* array (possibly empty).
*/
async function getLineup(sport, team, opts = {}) {
const sp = String(sport || 'nba').toLowerCase();
const t = norm(team);
if (!t) return [];
try {
if (sp === 'mlb') {
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
const date = opts.date || todayISO(opts.now);
const games = await mlb.getScheduleWithPitchers(date);
const game = (games || []).find((g) => matchesTeam(g.home, t) || matchesTeam(g.away, t));
if (!game) return [];
const side = matchesTeam(game.home, t) ? game.home : game.away;
const out = [];
if (side?.probablePitcher?.name) {
out.push({ player: side.probablePitcher.name, position: 'SP', battingOrder: null, projectedMinutes: null });
}
return out;
}
} catch (err) {
console.warn('[depthChart] getLineup failed:', sport, team, err.message);
}
return [];
}
function matchesTeam(side, t) {
if (!side || !t) return false;
const name = norm(side.team || side.name);
if (!name) return false;
const lastWord = name.split(' ').pop();
return name === t || name.includes(t) || (lastWord.length >= 3 && t.includes(lastWord));
}
/**
* Depth chart for a team: positions with starter/backup/thirdString. When no
* roster source is wired, returns a valid empty structure (graceful default).
* Pass opts.roster ([{player, position, depth}]) to build a real chart.
*/
async function getDepthChart(sport, team, opts = {}) {
const sp = String(sport || 'nba').toLowerCase();
const t = norm(team);
const base = { sport: sp, team: t, positions: [] };
const roster = Array.isArray(opts.roster) ? opts.roster : null;
if (!roster) return base;
const byPos = {};
for (const r of roster) {
const pos = norm(r.position) || 'UTIL';
(byPos[pos] = byPos[pos] || []).push(r);
}
base.positions = Object.entries(byPos).map(([position, players]) => {
const sorted = players.slice().sort((a, b) => (a.depth || 99) - (b.depth || 99));
return {
position,
starter: sorted[0]?.player || null,
backup: sorted[1]?.player || null,
thirdString: sorted[2]?.player || null,
};
});
return base;
}
/**
* Cascade projection: what happens to teammates' production when `player` is
* OUT. Foundation heuristic — uses the design's archetype cascade weights
* (usage sponges benefit most). Returns [] when we can't establish that the
* player is actually out or have no teammates to project onto.
* opts.teammates: [{player, archetype, baseUsage}] — injected by the caller.
*/
async function getCascadeProjection(sport, player, team, opts = {}) {
const teammates = Array.isArray(opts.teammates) ? opts.teammates : [];
if (!player || teammates.length === 0) return [];
// Distribute a fixed usage pool across teammates, weighting usage sponges and
// high-usage creators heavier (mirrors the design's cascade framing).
const POOL = Number.isFinite(opts.usagePool) ? opts.usagePool : 12; // %
const weightFor = (a) => {
const k = norm(a);
if (k.includes('USAGE SPONGE')) return 3;
if (k.includes('VOLUME') || k.includes('COMBO') || k.includes('POINT FORWARD')) return 2;
if (k.includes('ROLE GLUE') || k.includes('SPECIALIST')) return 0.5;
return 1;
};
const weights = teammates.map((m) => weightFor(m.archetype));
const total = weights.reduce((s, w) => s + w, 0) || 1;
return teammates.map((m, i) => {
const delta = +((POOL * weights[i]) / total).toFixed(1);
return {
player: m.player,
stat: 'usage',
delta: `+${delta}%`,
reason: `${player} OUT → ${m.player} absorbs touches`,
};
}).sort((a, b) => parseFloat(b.delta) - parseFloat(a.delta));
}
module.exports = {
getLineup,
getDepthChart,
getCascadeProjection,
_internals: { norm, matchesTeam, todayISO },
};
@@ -265,6 +265,55 @@ function fallbackLegacyResult(rawProp, errors) {
}; };
} }
/**
* Form score (0..100) from recent-vs-baseline averages (Session 43). Hot
* (l5 > l20) trends above 70; cold below. undefined when there's no recent avg.
*/
function computeFormScore(features = {}) {
const l5 = features.l5_avg;
if (!Number.isFinite(l5)) return undefined;
const base = Number.isFinite(features.l20_avg) ? features.l20_avg
: Number.isFinite(features.l10_avg) ? features.l10_avg : null;
if (base == null || base === 0) return 75;
const ratio = l5 / base;
return Math.round(Math.max(40, Math.min(99, 70 + (ratio - 1) * 60)));
}
function matchupGradeFromRank(rank) {
if (!Number.isFinite(rank)) return undefined;
if (rank >= 0.66) return 'A';
if (rank >= 0.5) return 'B+';
if (rank >= 0.33) return 'B';
return 'C';
}
/**
* Intelligence fields for the grade card (Session 43) — STAT CONTEXT + VYNDR
* INTELLIGENCE. Computed ONLY from the already-built feature vector (no extra
* I/O), so every field is optional and self-hides on the card when absent.
* NOTE: archetype is intentionally NOT set here — the per-prop feature vector
* doesn't carry a full multi-stat season line, so classifying it would just
* yield the fallback. The archetype strip lights up once the snapshot pipeline
* (Session 44) feeds per-player season lines into the grade response.
*/
function buildIntelFields(features = {}) {
const out = {};
const round1 = (n) => Math.round(n * 10) / 10;
if (Number.isFinite(features.l20_avg)) out.season_avg = round1(features.l20_avg);
else if (Number.isFinite(features.season_avg)) out.season_avg = round1(features.season_avg);
if (Number.isFinite(features.l10_avg)) out.last10_avg = round1(features.l10_avg);
else if (Number.isFinite(features.l5_avg)) out.last10_avg = round1(features.l5_avg);
const form = computeFormScore(features);
if (form != null) out.form = form;
if (Number.isFinite(features.usage_rate)) out.usage = `${round1(features.usage_rate)}%`;
else if (Number.isFinite(features.minutes_per_game)) out.usage = `${Math.round(features.minutes_per_game)} min`;
const matchup = matchupGradeFromRank(features.opp_rank_stat);
if (matchup) out.matchup_grade = matchup;
if (Number.isFinite(features.rest_days)) out.rest = `${features.rest_days}d rest`;
return out;
}
async function analyzeViaEngine1(rawProp = {}) { async function analyzeViaEngine1(rawProp = {}) {
const featureResult = await computeFeaturesForProp(rawProp); const featureResult = await computeFeaturesForProp(rawProp);
const { features, trap, consistency, prop, meta } = featureResult; const { features, trap, consistency, prop, meta } = featureResult;
@@ -308,6 +357,10 @@ async function analyzeViaEngine1(rawProp = {}) {
line: prop.line, line: prop.line,
}); });
// Session 43 — attach grade-card intelligence fields (stat context + VYNDR
// intelligence). Optional + self-hiding on the card; zero extra I/O.
Object.assign(legacy, buildIntelFields(features));
return legacy; return legacy;
} }
@@ -319,5 +372,8 @@ module.exports = {
fallbackLegacyResult, fallbackLegacyResult,
explainErrors, explainErrors,
ERROR_EXPLANATIONS, ERROR_EXPLANATIONS,
buildIntelFields,
computeFormScore,
matchupGradeFromRank,
}, },
}; };
+131 -9
View File
@@ -14,6 +14,15 @@
const { classify } = require('./archetypeService'); const { classify } = require('./archetypeService');
const toNum = (v) => {
const n = parseFloat(v);
return Number.isNaN(n) ? 0 : n;
};
const fmt3 = (v) => {
const s = String(v == null ? '' : v);
return s.startsWith('0.') ? s.slice(1) : s; // ".282" baseball style
};
/** /**
* Sanitize a player-name URL param (Montgomery's note). Decode, strip anything * Sanitize a player-name URL param (Montgomery's note). Decode, strip anything
* that isn't a letter/number/space or name punctuation (. - '), collapse * that isn't a letter/number/space or name punctuation (. - '), collapse
@@ -39,6 +48,106 @@ async function loadPlayerGrades(sport, name, cacheGetFn) {
return grades.filter((g) => normName(g.player_name || g.player) === target); return grades.filter((g) => normName(g.player_name || g.player) === target);
} }
// ── MLB raw-stat normalization (Session 43) ─────────────────────────
// Maps the raw statsapi.mlb.com season object into (a) classifier input and
// (b) the profile's display rows + last-10 log.
function mapMlbHitter(s) {
const pa = toNum(s.plateAppearances) || toNum(s.atBats);
return {
avg: toNum(s.avg), hr: toNum(s.homeRuns), rbi: toNum(s.rbi), sb: toNum(s.stolenBases),
ops: toNum(s.ops), runs: toNum(s.runs), doubles: toNum(s.doubles),
k_rate: pa > 0 ? (toNum(s.strikeOuts) / pa) * 100 : 0,
};
}
function mapMlbPitcher(s) {
const ip = toNum(s.inningsPitched);
const gs = toNum(s.gamesStarted);
return {
era: toNum(s.era), whip: toNum(s.whip),
k9: toNum(s.strikeoutsPer9Inn) || (ip > 0 ? (toNum(s.strikeOuts) / ip) * 9 : 0),
ip_per_start: gs > 0 ? ip / gs : 0,
saves: toNum(s.saves),
role: gs > 0 ? 'SP' : toNum(s.saves) > 0 ? 'CL' : 'RP',
};
}
function mlbSeasonRows(s, group) {
if (group === 'pitching') {
return [
{ k: 'ERA', v: String(s.era ?? '—') },
{ k: 'K', v: String(s.strikeOuts ?? '—') },
{ k: 'IP', v: String(s.inningsPitched ?? '—') },
{ k: 'WHIP', v: String(s.whip ?? '—') },
{ k: 'GS', v: String(s.gamesStarted ?? '—') },
];
}
return [
{ k: 'AVG', v: fmt3(s.avg) || '—' },
{ k: 'HR', v: String(s.homeRuns ?? '—') },
{ k: 'RBI', v: String(s.rbi ?? '—') },
{ k: 'OPS', v: fmt3(s.ops) || '—' },
{ k: 'GP', v: String(s.gamesPlayed ?? '—') },
];
}
function mlbLast10Rows(log, group) {
return (log || []).slice(-10).reverse().map((g) => {
const st = g.stat || {};
const summary = group === 'pitching'
? `${st.inningsPitched ?? '0'} IP · ${st.strikeOuts ?? 0} K`
: `${st.hits ?? 0}-${st.atBats ?? 0} · ${st.homeRuns ?? 0} HR`;
return { d: String(g.date || '').slice(5), opp: g.opponent ? String(g.opponent).slice(0, 3).toUpperCase() : '', stat: summary };
});
}
/**
* Resolve a player's REAL stats by sport (Session 43). Returns a normalized
* bundle or { found:false }. Best-effort + never throws — the profile renders
* regardless. Adapters are injectable for tests (opts.mlbAdapter/nbaClient).
*/
async function resolvePlayerStats(name, sport, opts = {}) {
const sp = String(sport || 'nba').toLowerCase();
try {
if (sp === 'mlb') {
const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter');
const res = await mlb.getPlayerStats(name);
if (!res || !res.found) return { found: false };
const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season);
return {
found: true,
team: res.team || '',
classifierInput,
season: mlbSeasonRows(res.season, res.group),
last10: mlbLast10Rows(res.last10, res.group),
splits: [],
};
}
if (sp === 'nba' || sp === 'wnba') {
// NBA/WNBA stats come from the Python nba_api service (nbaStatsClient).
// It's frequently offline in prod (localhost service) — degrade quietly.
const nba = opts.nbaClient || require('./nbaStatsClient');
const data = await nba.getSeasonAvg(name).catch(() => null);
if (!data || typeof data !== 'object') return { found: false };
const ppg = toNum(data.ppg ?? data.points);
if (!ppg) return { found: false };
const classifierInput = {
ppg, rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists),
bpg: toNum(data.bpg ?? data.blocks), spg: toNum(data.spg ?? data.steals),
threes: toNum(data.threes ?? data.fg3m), usg: toNum(data.usg ?? data.usage), pos: data.pos || data.position,
};
const season = [
{ k: 'PPG', v: String(classifierInput.ppg) }, { k: 'RPG', v: String(classifierInput.rpg) },
{ k: 'APG', v: String(classifierInput.apg) }, { k: 'BLK', v: String(classifierInput.bpg) },
];
return { found: true, team: data.team || '', classifierInput, season, last10: [], splits: [] };
}
} catch (err) {
console.warn('[playerIntel] resolvePlayerStats failed:', name, sp, err.message);
}
return { found: false };
}
/** Derive the VYNDR Intelligence metric row from whatever we have. */ /** Derive the VYNDR Intelligence metric row from whatever we have. */
function buildIntel(stats, arch, propCount) { function buildIntel(stats, arch, propCount) {
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n)); const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
@@ -69,10 +178,22 @@ async function getPlayerIntel(name, sport, opts = {}) {
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet; const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
const clean = sanitizePlayerName(name); const clean = sanitizePlayerName(name);
const sp = String(sport || 'nba').toLowerCase(); const sp = String(sport || 'nba').toLowerCase();
const stats = opts.stats || {};
const archetype = classify(sp, stats); // 1. Real season stats from the sport adapter (Session 43). Injectable for
// tests via opts.resolveStats; opts.stats short-circuits to caller-supplied.
let resolved;
if (opts.stats) {
resolved = { found: Object.keys(opts.stats).length > 0, classifierInput: opts.stats, season: opts.season || [], last10: opts.last10 || [], splits: opts.splits || [], team: opts.team || '' };
} else {
const resolver = opts.resolveStats || resolvePlayerStats;
resolved = await resolver(clean, sp, opts);
}
const realStats = resolved.classifierInput || {};
// 2. Archetype, now classified from REAL stats when we have them.
const archetype = classify(sp, realStats);
// 3. Tonight's graded props from the slate cache.
let props = []; let props = [];
try { try {
props = await loadPlayerGrades(sp, clean, cacheGetFn); props = await loadPlayerGrades(sp, clean, cacheGetFn);
@@ -88,22 +209,22 @@ async function getPlayerIntel(name, sport, opts = {}) {
confidence: p.confidence != null ? `${p.confidence}%` : null, confidence: p.confidence != null ? `${p.confidence}%` : null,
})); }));
const team = (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || ''; const team = resolved.team || (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || '';
return { return {
player: clean, player: clean,
sport: sp, sport: sp,
team, team,
found: props.length > 0 || Object.keys(stats).length > 0, found: !!resolved.found || props.length > 0,
archetype, archetype,
propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] }, propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] },
education: archetype.primary ? archetype.primary.education : '', education: archetype.primary ? archetype.primary.education : '',
season: opts.season || [], season: resolved.season || opts.season || [],
last10: opts.last10 || [], last10: resolved.last10 || opts.last10 || [],
splits: opts.splits || [], splits: resolved.splits || opts.splits || [],
gradeHistory: opts.gradeHistory || [], gradeHistory: opts.gradeHistory || [],
activeProps, activeProps,
intel: buildIntel(stats, archetype, props.length), intel: buildIntel(realStats, archetype, props.length),
injury: opts.injury || null, injury: opts.injury || null,
}; };
} }
@@ -145,5 +266,6 @@ module.exports = {
sanitizePlayerName, sanitizePlayerName,
getPlayerIntel, getPlayerIntel,
getLeaders, getLeaders,
_internals: { normName, loadPlayerGrades, buildIntel }, resolvePlayerStats,
_internals: { normName, loadPlayerGrades, buildIntel, mapMlbHitter, mapMlbPitcher, mlbSeasonRows, mlbLast10Rows },
}; };
+72
View File
@@ -0,0 +1,72 @@
// Session 43 — depth chart / lineup / cascade foundation. Sources injected.
const svc = require('../../src/services/depthChartService');
describe('getLineup (MLB, injected schedule)', () => {
const mlbAdapter = {
async getScheduleWithPitchers() {
return [{
home: { team: 'Atlanta Braves', probablePitcher: { name: 'Spencer Strider' } },
away: { team: 'Philadelphia Phillies', probablePitcher: { name: 'Zack Wheeler' } },
}];
},
};
it('returns the probable starting pitcher for the team', async () => {
const r = await svc.getLineup('mlb', 'Atlanta', { mlbAdapter, date: '2026-06-18' });
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({ player: 'Spencer Strider', position: 'SP' });
});
it('returns [] when the team is not playing / unknown', async () => {
expect(await svc.getLineup('mlb', 'Seattle', { mlbAdapter, date: '2026-06-18' })).toEqual([]);
expect(await svc.getLineup('mlb', '', { mlbAdapter })).toEqual([]);
});
it('degrades to [] when the adapter throws', async () => {
const bad = { async getScheduleWithPitchers() { throw new Error('down'); } };
expect(await svc.getLineup('mlb', 'Atlanta', { mlbAdapter: bad })).toEqual([]);
});
});
describe('getDepthChart', () => {
it('builds positions with starter/backup/third from an injected roster', async () => {
const chart = await svc.getDepthChart('nba', 'SA', {
roster: [
{ player: 'Wembanyama', position: 'C', depth: 1 },
{ player: 'Backup Big', position: 'C', depth: 2 },
{ player: 'Vassell', position: 'SG', depth: 1 },
],
});
const center = chart.positions.find((p) => p.position === 'C');
expect(center.starter).toBe('Wembanyama');
expect(center.backup).toBe('Backup Big');
});
it('returns a valid empty structure when no roster source', async () => {
const chart = await svc.getDepthChart('mlb', 'ATL');
expect(chart).toEqual({ sport: 'mlb', team: 'ATL', positions: [] });
});
});
describe('getCascadeProjection', () => {
it('distributes usage to teammates, weighting usage sponges heaviest', async () => {
const r = await svc.getCascadeProjection('nba', 'Keldon Murray', 'SA', {
teammates: [
{ player: 'Wembanyama', archetype: 'VOLUME SCORER' },
{ player: 'Bench Spark', archetype: 'USAGE SPONGE' },
{ player: 'Glue Guy', archetype: 'ROLE GLUE' },
],
});
expect(r).toHaveLength(3);
// usage sponge gets the biggest bump
expect(r[0].player).toBe('Bench Spark');
expect(r[0].delta.startsWith('+')).toBe(true);
expect(r[0].reason).toContain('OUT');
});
it('returns [] with no teammates / no player', async () => {
expect(await svc.getCascadeProjection('nba', 'X', 'SA', { teammates: [] })).toEqual([]);
expect(await svc.getCascadeProjection('nba', '', 'SA')).toEqual([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
// Session 43 — engine attaches grade-card intelligence fields (stat context +
// VYNDR intelligence) from the feature vector, and gradeAdapter maps them.
const { __internals } = require('../../src/services/intelligence/analyzeViaEngine1');
const { buildIntelFields, computeFormScore, matchupGradeFromRank } = __internals;
const { mapScanToGradeResult } = require('../../web/src/lib/gradeAdapter');
describe('computeFormScore', () => {
it('trends above 70 when recent > baseline (hot)', () => {
expect(computeFormScore({ l5_avg: 30, l20_avg: 24 })).toBeGreaterThan(70);
});
it('trends below 70 when recent < baseline (cold)', () => {
expect(computeFormScore({ l5_avg: 18, l20_avg: 26 })).toBeLessThan(70);
});
it('undefined when there is no recent average', () => {
expect(computeFormScore({})).toBeUndefined();
});
});
describe('matchupGradeFromRank', () => {
it('maps normalized opponent rank to a letter grade', () => {
expect(matchupGradeFromRank(0.9)).toBe('A');
expect(matchupGradeFromRank(0.55)).toBe('B+');
expect(matchupGradeFromRank(0.1)).toBe('C');
expect(matchupGradeFromRank(undefined)).toBeUndefined();
});
});
describe('buildIntelFields', () => {
it('builds stat context + form/usage/matchup/rest from features', () => {
const f = buildIntelFields({ l20_avg: 26.9, l10_avg: 28.4, l5_avg: 30.1, usage_rate: 31.2, opp_rank_stat: 0.7, rest_days: 2 });
expect(f.season_avg).toBe(26.9);
expect(f.last10_avg).toBe(28.4);
expect(f.form).toBeGreaterThan(70);
expect(f.usage).toBe('31.2%');
expect(f.matchup_grade).toBe('A');
expect(f.rest).toBe('2d rest');
});
it('omits fields with no data (so the card sections self-hide)', () => {
expect(buildIntelFields({})).toEqual({});
});
it('feeds straight into the grade card via gradeAdapter', () => {
const engineLike = { player: 'Wemby', stat: 'points', line: 26.5, grade: 'A', ...buildIntelFields({ l20_avg: 26.9, l10_avg: 28.4, l5_avg: 30, usage_rate: 31.2, opp_rank_stat: 0.7 }) };
const card = mapScanToGradeResult(engineLike);
expect(card.statContext).toMatchObject({ season: '26.9', last10: '28.4' });
expect(card.vyndrIntel.matchup).toBe('A');
expect(card.vyndrIntel.usage).toBe('31.2%');
});
});
+32
View File
@@ -0,0 +1,32 @@
// Session 43 — P0: avatar/More dropdowns must be clickable above the living-layer
// bars (Ticker + HeartbeatBar). Asserted in source: the nav floats above them
// and the menus carry an explicit z-index.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const nav = fs.readFileSync(path.join(WEB, 'components', 'Nav.tsx'), 'utf8');
describe('Nav dropdown z-index (P0 fix)', () => {
it('the nav element floats above the ticker/heartbeat (position+zIndex on the stacking-context nav)', () => {
// The nav block carries backdrop-filter; it must also set position+zIndex.
const navBlock = nav.slice(nav.indexOf('<nav'), nav.indexOf('backdropFilter') + 200);
expect(navBlock).toMatch(/position: 'relative'/);
expect(navBlock).toMatch(/zIndex: 2/);
});
it('the dropdown menus declare a high z-index', () => {
const menuZ = (nav.match(/zIndex: 100/g) || []).length;
expect(menuZ).toBeGreaterThanOrEqual(2); // More + avatar menus
});
});
describe('Nav links (Session 42/43)', () => {
it('MORE includes Explore and Settings -> /settings', () => {
expect(nav).toContain("label: 'Explore', href: '/explore'");
expect(nav).toContain("label: 'Settings', href: '/settings'");
});
it('avatar dropdown Settings links to /settings (not /settings/security)', () => {
expect(nav).toContain('href="/settings" role="menuitem"');
});
});
+89
View File
@@ -0,0 +1,89 @@
// Session 43 — real-stats wiring into playerIntelService. Adapters are injected
// so these run pure (no statsapi.mlb.com, no Python NBA service).
const svc = require('../../src/services/playerIntelService');
// A fake mlbStatsAdapter.getPlayerStats returning a power-pull hitter line.
const judgeAdapter = {
async getPlayerStats() {
return {
found: true, id: 592450, name: 'Aaron Judge', team: 'New York Yankees', position: 'RF', group: 'hitting',
season: { avg: '.288', homeRuns: 34, rbi: 87, ops: '1.012', gamesPlayed: 92, stolenBases: 9, runs: 80, doubles: 18, strikeOuts: 120, plateAppearances: 400, atBats: 330 },
last10: [
{ date: '2026-06-15', opponent: 'Boston Red Sox', stat: { hits: 2, atBats: 4, homeRuns: 1 } },
{ date: '2026-06-16', opponent: 'Tampa Bay Rays', stat: { hits: 1, atBats: 3, homeRuns: 0 } },
],
};
},
};
const aceAdapter = {
async getPlayerStats() {
return {
found: true, id: 1, name: 'Tarik Skubal', team: 'Detroit Tigers', position: 'P', group: 'pitching',
season: { era: '2.41', strikeOuts: 130, inningsPitched: '110.0', whip: '0.92', gamesStarted: 17, strikeoutsPer9Inn: '10.6', saves: 0 },
last10: [{ date: '2026-06-14', opponent: 'Chicago White Sox', stat: { inningsPitched: '7.0', strikeOuts: 9 } }],
};
},
};
describe('resolvePlayerStats (MLB, injected adapter)', () => {
it('normalizes a hitter into classifier input + display rows + last10', async () => {
const r = await svc.resolvePlayerStats('Aaron Judge', 'mlb', { mlbAdapter: judgeAdapter });
expect(r.found).toBe(true);
expect(r.team).toBe('New York Yankees');
expect(r.classifierInput.hr).toBe(34);
expect(r.classifierInput.k_rate).toBeGreaterThan(0);
expect(r.season.find((s) => s.k === 'HR').v).toBe('34');
expect(r.season.find((s) => s.k === 'AVG').v).toBe('.288');
expect(r.last10.length).toBe(2);
expect(r.last10[0].stat).toContain('HR'); // most-recent-first summary
});
it('normalizes a pitcher (group=pitching) into ERA/K9/role', async () => {
const r = await svc.resolvePlayerStats('Tarik Skubal', 'mlb', { mlbAdapter: aceAdapter });
expect(r.classifierInput.era).toBeCloseTo(2.41, 2);
expect(r.classifierInput.k9).toBeCloseTo(10.6, 1);
expect(r.classifierInput.role).toBe('SP');
expect(r.season.find((s) => s.k === 'ERA').v).toBe('2.41');
});
it('returns found:false when the adapter has no data', async () => {
const r = await svc.resolvePlayerStats('Nobody', 'mlb', { mlbAdapter: { async getPlayerStats() { return { found: false }; } } });
expect(r.found).toBe(false);
});
});
describe('getPlayerIntel with real stats (Session 43)', () => {
it('returns found:true + real season + an archetype classified from real stats', async () => {
const r = await svc.getPlayerIntel('Aaron Judge', 'mlb', {
cacheGet: async () => null,
resolveStats: (name, sport) => svc.resolvePlayerStats(name, sport, { mlbAdapter: judgeAdapter }),
});
expect(r.found).toBe(true);
expect(r.team).toBe('New York Yankees');
expect(r.season.length).toBeGreaterThan(0);
// 34 HR + high K-rate → POWER PULL, not the empty-stats fallback.
expect(['POWER PULL', 'POWER SLUGGER', 'RUN PRODUCER']).toContain(r.archetype.primary.name);
expect(r.archetype.primary.name).not.toBe('UTILITY PLAYER');
});
it('classifies an ace pitcher from real stats', async () => {
const r = await svc.getPlayerIntel('Tarik Skubal', 'mlb', {
cacheGet: async () => null,
resolveStats: (name, sport) => svc.resolvePlayerStats(name, sport, { mlbAdapter: aceAdapter }),
});
expect(r.archetype.primary.name).toBe('ACE');
expect(r.found).toBe(true);
});
it('still degrades gracefully when no stats and no props (found:false)', async () => {
const r = await svc.getPlayerIntel('Ghost Player', 'mlb', {
cacheGet: async () => null,
resolveStats: async () => ({ found: false }),
});
expect(r.found).toBe(false);
expect(r.archetype.primary).toBeTruthy(); // fallback archetype still present
expect(r.season).toEqual([]);
});
});
+26
View File
@@ -0,0 +1,26 @@
// Session 43 — Phase 6 mobile + cosmetics.
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('Player profile hero name (mobile truncation fix)', () => {
const page = read('app/player/[name]/page.tsx');
const css = read('app/globals.css');
it('hero name uses overflow-safe wrapping (no clipping)', () => {
expect(page).toContain('player-hero-name');
expect(page).toContain("overflowWrap: 'anywhere'");
});
it('mobile CSS shrinks + wraps the hero name', () => {
expect(css).toMatch(/\.player-hero-name\s*\{[^}]*font-size: 24px/);
});
});
describe('No runtime font CDN (Session 41 fix still holds)', () => {
it('layout has no fonts.googleapis.com stylesheet link', () => {
const layout = read('app/layout.tsx');
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
expect(layout).toContain("from 'next/font/google'");
});
});
+73
View File
@@ -0,0 +1,73 @@
// Session 43 — slate adapter: player-grouped strips + MLB pitchers + BookChip.
const fs = require('fs');
const path = require('path');
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
const adapter = require('../../web/src/lib/slateAdapter');
describe('groupPropsByPlayer', () => {
it('groups props so each player appears once (name not repeated)', () => {
const out = adapter.groupPropsByPlayer([
{ player: 'Austin Riley', team: 'ATL', stat: 'Hits', line: 1.5, side: 'Over', grade: 'A' },
{ player: 'Austin Riley', team: 'ATL', stat: 'Total Bases', line: 1.5, side: 'Over', grade: 'B+' },
{ player: 'Bryce Harper', team: 'PHI', stat: 'TB', line: 1.5, side: 'Over', grade: 'A' },
]);
expect(out).toHaveLength(2);
expect(out[0].player).toBe('Austin Riley');
expect(out[0].props).toHaveLength(2);
expect(out[0].props[0].side).toBe('O');
expect(out[1].player).toBe('Bryce Harper');
});
it('attaches an archetype when a lookup is provided', () => {
const out = adapter.groupPropsByPlayer(
[{ player: 'Riley', stat: 'Hits', line: 1.5, side: 'Over', grade: 'A' }],
() => ({ primary: 'POWER PULL' }),
);
expect(out[0].archetype).toEqual({ primary: 'POWER PULL' });
});
it('returns [] for empty / non-array input', () => {
expect(adapter.groupPropsByPlayer(null)).toEqual([]);
expect(adapter.groupPropsByPlayer([])).toEqual([]);
});
});
describe('mapPitchers', () => {
it('maps MLB probable pitchers to the GameCard shape', () => {
const p = adapter.mapPitchers({
sport: 'mlb',
away: { probablePitcher: { name: 'Spencer Strider' } },
home: { probablePitcher: { name: 'Zack Wheeler' } },
awayPitcherERA: 3.21, homePitcherERA: 2.89,
});
expect(p.away.name).toBe('Spencer Strider');
expect(p.away.era).toBe('3.21');
expect(p.home.name).toBe('Zack Wheeler');
});
it('returns undefined for non-MLB or no probables', () => {
expect(adapter.mapPitchers({ sport: 'nba' })).toBeUndefined();
expect(adapter.mapPitchers({ sport: 'mlb' })).toBeUndefined();
});
});
describe('mapScheduleToGameCards includes the new fields', () => {
it('builds playerStrips + pitchers on each card', () => {
const cards = adapter.mapScheduleToGameCards(
[{ id: 'ATL-PHI', sport: 'mlb', awayTeam: { abbreviation: 'ATL', name: 'Braves' }, homeTeam: { abbreviation: 'PHI', name: 'Phillies' }, away: { probablePitcher: { name: 'Strider' } }, home: { probablePitcher: { name: 'Wheeler' } } }],
{},
[],
[{ player: 'Austin Riley', team: 'ATL', stat: 'Hits', line: 1.5, grade: 'A', side: 'Over' }],
);
expect(cards[0].playerStrips[0].player).toBe('Austin Riley');
expect(cards[0].pitchers.away.name).toBe('Strider');
});
});
describe('legacy GameCard uses BookChip (brand colors)', () => {
it('renders book chips instead of plain grey text', () => {
const src = fs.readFileSync(path.join(WEB, 'components', 'GameCard.tsx'), 'utf8');
expect(src).toContain('BookChip');
expect(src).toContain('book={r.book}');
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -1246,6 +1246,9 @@ html[data-font="readable"] .wm::after { opacity: 0.45 !important; }
/* Grade hero zone: full-width, slightly smaller letter on phones. */ /* Grade hero zone: full-width, slightly smaller letter on phones. */
@media (max-width: 640px) { @media (max-width: 640px) {
.grade-hero { font-size: 80px !important; } .grade-hero { font-size: 80px !important; }
/* Session 43 — player-profile hero name was clipping (e.g. "Wembanyam")
at 390px. Shrink + wrap instead of overflowing the flex row. */
.player-hero-name { font-size: 24px !important; overflow-wrap: anywhere; }
} }
/* Book-line tables scroll horizontally on small screens with a sticky /* Book-line tables scroll horizontally on small screens with a sticky
+2 -2
View File
@@ -86,8 +86,8 @@ export default function PlayerProfilePage() {
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', background: 'repeating-linear-gradient(0deg, rgba(0,212,160,0.045) 0px, rgba(0,212,160,0.045) 1px, transparent 1px, transparent 4px)' }} /> <div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', background: 'repeating-linear-gradient(0deg, rgba(0,212,160,0.045) 0px, rgba(0,212,160,0.045) 1px, transparent 1px, transparent 4px)' }} />
<div style={{ position: 'relative', display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}> <div style={{ position: 'relative', display: 'flex', gap: 20, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div className="mono" style={{ flex: 'none', width: 72, height: 72, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'radial-gradient(circle at 30% 25%, #14241F, #0A100E)', border: '1.5px solid #00D4A066', color: 'var(--g-a)', fontWeight: 700, fontSize: 30, boxShadow: '0 0 24px #00D4A022' }}>{initials(p.player)}</div> <div className="mono" style={{ flex: 'none', width: 72, height: 72, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'radial-gradient(circle at 30% 25%, #14241F, #0A100E)', border: '1.5px solid #00D4A066', color: 'var(--g-a)', fontWeight: 700, fontSize: 30, boxShadow: '0 0 24px #00D4A022' }}>{initials(p.player)}</div>
<div style={{ flex: 1, minWidth: 240 }}> <div style={{ flex: 1, minWidth: 0 }}>
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.015em', lineHeight: 1.05 }}>{p.player}</h1> <h1 className="player-hero-name" style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.015em', lineHeight: 1.05, overflowWrap: 'anywhere' }}>{p.player}</h1>
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}> <div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<SportBadge sport={p.sport} /> <SportBadge sport={p.sport} />
{p.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>} {p.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>}
+3 -1
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow'; import type { PropRowProp, PropRowResult, Tier } from '@/components/PropRow';
import SportBadge from '@/components/vyndr/SportBadge'; import SportBadge from '@/components/vyndr/SportBadge';
import SectionHead from '@/components/vyndr/SectionHead'; import SectionHead from '@/components/vyndr/SectionHead';
import BookChip from '@/components/vyndr/BookChip';
import { detectBestLines } from '@/lib/slateAdapter'; import { detectBestLines } from '@/lib/slateAdapter';
// Session 19 — PlayerCard groups props by player so a single player // Session 19 — PlayerCard groups props by player so a single player
// with 4 props renders as ONE card with their headshot + 4 stat // with 4 props renders as ONE card with their headshot + 4 stat
@@ -345,7 +346,8 @@ export default function GameCard(props: GameCardProps) {
<div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div> <div className="label" style={{ fontSize: 10, textAlign: 'center' }}>O/U</div>
{lineRows.map((r) => ( {lineRows.map((r) => (
<span key={r.book} style={{ display: 'contents' }}> <span key={r.book} style={{ display: 'contents' }}>
<div className="mono" style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-1)', textTransform: 'capitalize', paddingLeft: 2, alignSelf: 'center' }}>{r.book}</div> {/* Session 43 — brand-colored book chip (was plain grey text). */}
<div style={{ paddingLeft: 2, alignSelf: 'center' }}><BookChip book={r.book} size="sm" showName /></div>
<LineCell value={r.awayML} best={r.bestAway} worst={r.worstAway} /> <LineCell value={r.awayML} best={r.bestAway} worst={r.worstAway} />
<LineCell value={r.homeML} best={r.bestHome} worst={r.worstHome} /> <LineCell value={r.homeML} best={r.bestHome} worst={r.worstHome} />
<LineCell value={r.ou} /> <LineCell value={r.ou} />
+11 -1
View File
@@ -71,6 +71,14 @@ export default function Nav() {
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50 }}> <div style={{ position: 'fixed', top: 0, left: 0, right: 0, zIndex: 50 }}>
<nav <nav
style={{ style={{
// Session 43 (P0) — the nav's backdrop-filter creates a stacking
// context; the Ticker + HeartbeatBar render after it as siblings, so
// without this they painted OVER the avatar/More dropdowns (which
// overflow below the 60px bar) and ate the clicks. position+zIndex
// floats the whole nav (and its dropdowns) above those living-layer
// bars so menu items are clickable again.
position: 'relative',
zIndex: 2,
height: 60, height: 60,
borderBottom: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
background: 'rgba(6, 6, 11, 0.86)', background: 'rgba(6, 6, 11, 0.86)',
@@ -130,6 +138,7 @@ export default function Nav() {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
top: 'calc(100% + 8px)', top: 'calc(100% + 8px)',
zIndex: 100,
minWidth: 180, minWidth: 180,
background: 'var(--bg-2)', background: 'var(--bg-2)',
border: '1px solid var(--border-hi)', border: '1px solid var(--border-hi)',
@@ -273,6 +282,7 @@ export default function Nav() {
position: 'absolute', position: 'absolute',
right: 0, right: 0,
top: 'calc(100% + 8px)', top: 'calc(100% + 8px)',
zIndex: 100,
minWidth: 220, minWidth: 220,
background: 'var(--bg-2)', background: 'var(--bg-2)',
border: '1px solid var(--border-hi)', border: '1px solid var(--border-hi)',
@@ -293,7 +303,7 @@ export default function Nav() {
</div> </div>
</div> </div>
<a href="/account" role="menuitem" style={menuItem}>Account</a> <a href="/account" role="menuitem" style={menuItem}>Account</a>
<a href="/settings/security" role="menuitem" style={menuItem}>Settings</a> <a href="/settings" role="menuitem" style={menuItem}>Settings</a>
{tier === 'free' && ( {tier === 'free' && (
<a href="/pricing" role="menuitem" style={{ ...menuItem, color: 'var(--g-a)' }}> <a href="/pricing" role="menuitem" style={{ ...menuItem, color: 'var(--g-a)' }}>
Upgrade $14.99/mo Upgrade $14.99/mo
+60
View File
@@ -88,6 +88,11 @@ function mapScheduleToGameCards(schedule, gamelines, streaks, grades) {
venue: g.venue || undefined, venue: g.venue || undefined,
lines: mapGameLines(linesEntry), lines: mapGameLines(linesEntry),
props: mapGradedProps(grades, g), props: mapGradedProps(grades, g),
// Session 43 — design enhanced-card fields (consumed by vyndr/GameCard;
// legacy GameCard ignores the extras). playerStrips = props grouped so the
// name appears once; pitchers = MLB probables when published.
playerStrips: groupPropsByPlayer(mapGradedProps(grades, g)),
pitchers: mapPitchers({ ...g, sport: g.sport }),
streaks: mapStreaks(streaks, g), streaks: mapStreaks(streaks, g),
}; };
}); });
@@ -117,10 +122,65 @@ function mapStreaks(streaks, game) {
.map((s) => ({ player: s.player, text: s.text || s.description || '' })); .map((s) => ({ player: s.player, text: s.text || s.description || '' }));
} }
/**
* Group graded props by player → the design's enhanced-card `playerStrips`
* (Session 43): player name once, archetype + stats placeholder, all props on
* one line. `archetypeLookup(player)` is optional (sync) — when absent the
* strip renders without an archetype badge (still valid).
*/
function groupPropsByPlayer(props, archetypeLookup) {
if (!Array.isArray(props)) return [];
const byPlayer = {};
const order = [];
for (const p of props) {
if (!p || !p.player) continue;
if (!byPlayer[p.player]) {
const archetype = typeof archetypeLookup === 'function' ? archetypeLookup(p.player) : undefined;
byPlayer[p.player] = {
player: p.player,
team: p.team || '',
archetype: archetype || undefined,
stats: [], // season stats wired when the stats cache lands (Session 44)
props: [],
};
order.push(p.player);
}
byPlayer[p.player].props.push({
stat: p.stat,
line: p.line,
side: (p.side || 'Over').toString().charAt(0).toUpperCase(),
grade: p.grade,
});
}
return order.map((name) => byPlayer[name]);
}
/**
* Map an MLB schedule game's probable pitchers → the GameCard `pitchers` shape.
* Returns undefined for non-MLB or when no probables are published.
*/
function mapPitchers(game) {
if (!game || String(game.sport || '').toLowerCase() !== 'mlb') return undefined;
const a = game.away?.probablePitcher || game.awayPitcher;
const h = game.home?.probablePitcher || game.homePitcher;
if (!a && !h) return undefined;
const one = (p, era, arch) => ({
name: (p && (p.name || p.fullName)) || (typeof p === 'string' ? p : '') || 'TBD',
era: era != null ? String(era) : (p && p.era != null ? String(p.era) : '—'),
archetype: arch || (p && p.archetype) || undefined,
});
return {
away: one(a, game.awayPitcherERA, game.awayPitcherArchetype),
home: one(h, game.homePitcherERA, game.homePitcherArchetype),
};
}
module.exports = { module.exports = {
parseAmericanOdds, parseAmericanOdds,
detectBestLines, detectBestLines,
mapGameLines, mapGameLines,
mapScheduleToGameCards, mapScheduleToGameCards,
formatGameTime, formatGameTime,
groupPropsByPlayer,
mapPitchers,
}; };