Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)
Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections). - Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5 WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend. Frontend visual map web/src/lib/archetypes.js (colors verified == backend). ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar). - StatStrip (compact/expanded): player name once, horizontal mono stats, inline GradeBadge props, onPlayerClick -> profile. - Stats API: extended src/routes/stats.js with /player/:name, /leaders, /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes name param; grades cache; graceful on cold cache). Next proxies added. - Player Profile /player/[name]: all 9 design sections, graceful empty states. - Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via gradeAdapter.buildIntelFields). Player-name links wired everywhere. - Settings page replaces the S41 redirect (account/subscription/notifications/ display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the real /settings/security MFA page — does not replace it. + BookChip. - Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added Explore + Settings to Nav MORE. Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate redesign, Stats Explorer sub-panels. Backend 1940 -> 2011 tests (+71), 157 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+58
-3
@@ -1,11 +1,66 @@
|
|||||||
# VYNDR — Build State
|
# VYNDR — Build State
|
||||||
|
|
||||||
## Last Updated
|
## Last Updated
|
||||||
2026-06-17
|
2026-06-18
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
SHIP BUILD v41.0 — P0 audit fixes (MLB stat gate, broken routes, profile tier,
|
SHIP BUILD v42.0 — Player Intelligence System (archetypes, stat strips, player
|
||||||
self-hosted fonts). Design conversion (33–39) remains COMPLETE.
|
profile, enhanced cards, settings, stats explorer). Built from the Claude Design
|
||||||
|
"VYNDR Player Intelligence" bundle.
|
||||||
|
|
||||||
|
## Session 42 (2026-06-18) — SHIPPED ✅ PLAYER INTELLIGENCE SYSTEM
|
||||||
|
|
||||||
|
Built the full Player Intelligence design bundle (10 sections; the 6 spec items
|
||||||
|
+ extras). Backend 1940 → **2011 tests** (+71), 157 suites. Web build clean
|
||||||
|
(exit 0). Both gates green.
|
||||||
|
|
||||||
|
### What shipped (by design section)
|
||||||
|
1. **Archetype system** — `src/services/archetypeService.js`: 41 archetypes
|
||||||
|
(15 NBA + 5 WNBA-unique + 15 MLB + 6 soccer), each with tag/color/glyph/
|
||||||
|
description/propDNA/education. `classify(sport, stats)` returns PRIMARY +
|
||||||
|
optional SECONDARY + a normalized `blend`. Visual map mirrored in
|
||||||
|
`web/src/lib/archetypes.js` (CommonJS, colors verified == backend).
|
||||||
|
Components: `ArchetypeBadge` (full/ghost/tint + glyphs), `ArchetypeBlend`
|
||||||
|
(the production-DNA bar).
|
||||||
|
2. **StatStrip** — `components/vyndr/StatStrip.tsx`, compact + expanded. The
|
||||||
|
player name appears ONCE; stats flow horizontally (JetBrains Mono); props
|
||||||
|
inline with GradeBadge; `onPlayerClick` → profile.
|
||||||
|
3. **Stats API + Player Profile** — extended `src/routes/stats.js` with
|
||||||
|
`/player/:name`, `/leaders`, `/game/:id` (rate-limited 60/min). Aggregation
|
||||||
|
in `src/services/playerIntelService.js` (sanitizes the name param; reads
|
||||||
|
grades:{sport} cache for props; graceful on cold caches). Page:
|
||||||
|
`app/player/[name]/page.tsx` — all 9 design sections (hero+DNA blend, injury,
|
||||||
|
prop DNA, VYNDR intelligence, active props, season, last 10, splits, grade
|
||||||
|
history). Next proxies under `app/api/stats/player|leaders`.
|
||||||
|
4. **Enhanced game cards + Grade Result** — `vyndr/GameCard` gained optional
|
||||||
|
MLB starting pitchers + player-grouped `playerStrips` (StatStrip, name once);
|
||||||
|
`GradeResultCard` gained archetype strip + STAT CONTEXT + VYNDR INTELLIGENCE
|
||||||
|
(all optional/self-hiding; populated by `gradeAdapter.buildIntelFields`).
|
||||||
|
Header player name now links to the profile.
|
||||||
|
5. **Settings page** — `app/settings/page.tsx` REPLACES the S41 redirect:
|
||||||
|
account (tier from useAuth), subscription, notifications, display (→ Prefs
|
||||||
|
modal via `window.__prefs`), responsible play, danger zone (delete button
|
||||||
|
gated on typing DELETE exactly). LINKS to `/settings/security` — does NOT
|
||||||
|
replace the real MFA page. + `BookChip` component (`lib/books.js`).
|
||||||
|
6. **Stats Explorer (bonus, design §07)** — `app/explore/page.tsx`: tonight's
|
||||||
|
league leaderboard from the real `/api/stats/leaders`, sport tabs, search,
|
||||||
|
rows → player profile. Added to the Nav MORE menu (Explore + Settings→/settings).
|
||||||
|
|
||||||
|
### Player-name links wired
|
||||||
|
Game cards, Grade Result header, Stats Explorer rows, profile active props — all
|
||||||
|
route to `/player/:name?sport=` via `lib/playerHref.js`.
|
||||||
|
|
||||||
|
### Deferred (design sections present but NOT built this session)
|
||||||
|
Team Hub (§09), Offseason Intel (§08), and the Slate redesign (§10), plus the
|
||||||
|
Stats Explorer sub-panels (hit-rate trends, head-to-head, market-vs-VYNDR,
|
||||||
|
matchup ratings). These need historical / projection / depth-chart data
|
||||||
|
pipelines that don't exist yet — they belong with the Session-43 snapshot
|
||||||
|
pipeline. The archetype + leaders + profile foundations they'd build on are now
|
||||||
|
in place.
|
||||||
|
|
||||||
|
## Session 41 (2026-06-17) — SHIPPED ✅ P0 AUDIT FIXES
|
||||||
|
|
||||||
|
The Chrome audit's P0 list only. No features. Backend 1907 → **1940 tests**
|
||||||
|
|
||||||
## Session 41 (2026-06-17) — SHIPPED ✅ P0 AUDIT FIXES
|
## Session 41 (2026-06-17) — SHIPPED ✅ P0 AUDIT FIXES
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,40 @@ The 7-session design conversion (33–39) is done and parity-verified against §
|
|||||||
- **Profile tier** reads from `useAuth().tier` (the nav's source), not the
|
- **Profile tier** reads from `useAuth().tier` (the nav's source), not the
|
||||||
`/api/user/profile` fetch, so the two can't disagree.
|
`/api/user/profile` fetch, so the two can't disagree.
|
||||||
|
|
||||||
|
## Player Intelligence System (Session 42 — non-obvious)
|
||||||
|
Built from the Claude Design "VYNDR Player Intelligence" bundle.
|
||||||
|
- **Archetypes (41, NOT 45)** — 15 NBA + 5 WNBA-unique + 15 MLB + 6 soccer. The
|
||||||
|
canonical registry is `src/services/archetypeService.js` (`ARCHETYPES` keyed by
|
||||||
|
UPPERCASE name; classify/getArchetype). The frontend visual map (color + glyph
|
||||||
|
SVG + desc) is duplicated in `web/src/lib/archetypes.js` because the browser
|
||||||
|
can't import the backend; a test asserts the colors MATCH. Colors are unique
|
||||||
|
WITHIN a sport but REUSED across sports (a TWO-WAY archetype is purple in NBA +
|
||||||
|
WNBA) — don't "dedupe" them. `classify(sport, stats)` is a feature-scoring
|
||||||
|
classifier → `{ primary, secondary|null, blend: [{archetype, weight}] }`.
|
||||||
|
- **StatStrip rule** — player name appears ONCE; stats are horizontal mono runs.
|
||||||
|
Never stack the name per stat. `components/vyndr/StatStrip.tsx` (compact +
|
||||||
|
expanded). `vyndr/GameCard` prefers `playerStrips` (grouped) over legacy
|
||||||
|
per-prop rows; both kept for back-comat.
|
||||||
|
- **Stats API** — `src/routes/stats.js` ALREADY existed (filters/public/live);
|
||||||
|
Session 42 ADDED `/player/:name`, `/leaders`, `/game/:id` (don't recreate the
|
||||||
|
file). Aggregation logic is in `src/services/playerIntelService.js` (testable;
|
||||||
|
inject `cacheGet`). The name param is sanitized there (strip non-name chars,
|
||||||
|
cap 60). Reads `grades:{sport}` cache for a player's props. Frontend needs the
|
||||||
|
Next proxy (`app/api/stats/player|leaders/route.ts`) — Express isn't reachable
|
||||||
|
from the browser directly (same rule as S25).
|
||||||
|
- **Player links** — always via `web/src/lib/playerHref.js` → `/player/:name?sport=`.
|
||||||
|
- **GradeResultCard / gradeAdapter** — new card sections (archetypeBlend, propDNA,
|
||||||
|
statContext, vyndrIntel) are OPTIONAL + self-hiding; `gradeAdapter.buildIntelFields`
|
||||||
|
only populates them when the engine supplies `archetype`/`season_avg`/`form`/etc.
|
||||||
|
They light up once the Session-43 data pipeline feeds them — no empty boxes now.
|
||||||
|
- **Settings** — `/settings` is now a REAL page (replaced the S41 redirect). It
|
||||||
|
LINKS to `/settings/security` (the real MFA page) — never overwrite that. Danger
|
||||||
|
zone delete is gated on `deleteText === 'DELETE'`; there's NO backend deletion
|
||||||
|
endpoint yet, so the confirmed action surfaces an honest "email support" message
|
||||||
|
rather than faking success.
|
||||||
|
- **Components added to the barrel**: ArchetypeBadge, ArchetypeBlend, StatStrip,
|
||||||
|
BookChip (`@/components/vyndr`). Book brand map = `web/src/lib/books.js`.
|
||||||
|
|
||||||
## Active Skills
|
## Active Skills
|
||||||
- vyndr-voice (all user-facing output)
|
- vyndr-voice (all user-facing output)
|
||||||
- prop-analysis (grading methodology)
|
- prop-analysis (grading methodology)
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||||
const { getStatFilters } = require('../config/statFilters');
|
const { getStatFilters } = require('../config/statFilters');
|
||||||
|
const { createRateLimit } = require('../middleware/rateLimit');
|
||||||
|
const { getPlayerIntel, getLeaders } = require('../services/playerIntelService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
|
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
|
||||||
|
|
||||||
|
// Player-intelligence endpoints are public + cache-backed (Session 42). Cap
|
||||||
|
// per-IP like the other public cached routers (60/min).
|
||||||
|
const intelLimit = createRateLimit({ windowMs: 60_000, max: 60 });
|
||||||
|
|
||||||
// GET /filters/:sport — stat-filter categories for the StatFilterPills UI
|
// GET /filters/:sport — stat-filter categories for the StatFilterPills UI
|
||||||
// (Session 23). Lets the frontend stay data-driven without re-declaring
|
// (Session 23). Lets the frontend stay data-driven without re-declaring
|
||||||
// the category list. NO auth / NO DB — pure config.
|
// the category list. NO auth / NO DB — pure config.
|
||||||
@@ -117,4 +123,46 @@ router.get('/live', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /player/:name?sport=nba — full player intelligence payload (Session 42):
|
||||||
|
// standard stats + archetype DNA + VYNDR intelligence + tonight's graded props.
|
||||||
|
// Name is sanitized inside the service. Always 200 with a valid shape (degrades
|
||||||
|
// gracefully on cold caches) — the profile page must never hard-fail.
|
||||||
|
router.get('/player/:name', intelLimit, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||||
|
const data = await getPlayerIntel(req.params.name, sport);
|
||||||
|
res.set(MISSION_HEADER).json(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[stats/player]', err.message);
|
||||||
|
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
|
||||||
|
// graded props by confidence) for the Terminal / Stats Explorer.
|
||||||
|
router.get('/leaders', intelLimit, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||||
|
const leaders = await getLeaders(sport, { stat: req.query.stat, limit: req.query.limit });
|
||||||
|
res.set(MISSION_HEADER).json({ sport, stat: req.query.stat || null, leaders });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[stats/leaders]', err.message);
|
||||||
|
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /game/:id?sport=nba — game-level enrichment (pitchers, injuries, leaders,
|
||||||
|
// team records) via the ESPN summary (Session 30). Best-effort.
|
||||||
|
router.get('/game/:id', intelLimit, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||||
|
const { getGameSummary } = require('../services/scheduleService');
|
||||||
|
const summary = await getGameSummary(sport, req.params.id);
|
||||||
|
res.set(MISSION_HEADER).json(summary || { error: 'not_found' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[stats/game]', err.message);
|
||||||
|
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* archetypeService — player archetype classification (Session 42).
|
||||||
|
*
|
||||||
|
* Pure logic: categorize a player by their prop-behavior pattern from season
|
||||||
|
* averages + usage. Returns a PRIMARY archetype, an optional SECONDARY, and a
|
||||||
|
* weighted `blend` (the production-DNA bar in the design's player profile).
|
||||||
|
*
|
||||||
|
* The archetype NAMES + colors + glyph keys are the contract shared with the
|
||||||
|
* frontend `ArchetypeBadge` (web/src/components/vyndr/ArchetypeBadge.tsx) and
|
||||||
|
* `ArchetypeData` (web/src/lib/archetypes.ts) — ported verbatim from the
|
||||||
|
* "VYNDR Player Intelligence" design (ArchetypeBadge.dc.html MAP). Keep the
|
||||||
|
* hex/glyph in sync across all three.
|
||||||
|
*
|
||||||
|
* No API calls, no I/O — feed it a stat object, get a classification. That's
|
||||||
|
* why it lives in services/ and is unit-testable in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Archetype registry ──────────────────────────────────────────────
|
||||||
|
// color + glyph match the design's badge map. propDNA = which props this
|
||||||
|
// archetype makes reliable vs volatile. education = the profile's "what does
|
||||||
|
// this mean" copy.
|
||||||
|
const ARCHETYPES = {
|
||||||
|
// ───────── NBA (15) ─────────
|
||||||
|
'VOLUME SCORER': {
|
||||||
|
tag: 'VOL SCORER', sport: 'nba', color: '#FF6B4A', glyph: 'triangle',
|
||||||
|
description: 'High usage, shot-dependent scorer',
|
||||||
|
propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] },
|
||||||
|
education: 'Volume scorers carry a heavy shot diet, so their points props track usage closely. When they get their normal touches, points clear reliably; assists and threes swing with game script.',
|
||||||
|
},
|
||||||
|
'FLOOR GENERAL': {
|
||||||
|
tag: 'FLOOR GEN', sport: 'nba', color: '#4A9EFF', glyph: 'node',
|
||||||
|
description: 'Assist-heavy playmaker',
|
||||||
|
propDNA: { reliable: ['assists'], volatile: ['points', 'threes'] },
|
||||||
|
education: 'Floor generals create for others first. Assists are their most stable prop because the offense runs through them; their scoring fluctuates with shot selection and matchup.',
|
||||||
|
},
|
||||||
|
'TWO-WAY ANCHOR': {
|
||||||
|
tag: 'TW ANCHOR', sport: 'nba', color: '#A78BFA', glyph: 'shield',
|
||||||
|
description: 'Defense, rebounds, blocks',
|
||||||
|
propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] },
|
||||||
|
education: 'Two-way anchors generate value on the defensive glass and at the rim. Rebounds and blocks are matchup-resilient; their scoring depends on how much offense flows their way.',
|
||||||
|
},
|
||||||
|
'STRETCH BIG': {
|
||||||
|
tag: 'STRETCH BIG', sport: 'nba', color: '#2DD4BF', glyph: 'target',
|
||||||
|
description: 'Floor-spacing shooting big',
|
||||||
|
propDNA: { reliable: ['threes', 'rebounds'], volatile: ['assists', 'blocks'] },
|
||||||
|
education: 'Stretch bigs space the floor and crash the glass. Threes and rebounds are their bread and butter; assists and blocks are situational.',
|
||||||
|
},
|
||||||
|
'USAGE SPONGE': {
|
||||||
|
tag: 'USG SPONGE', sport: 'nba', color: '#FFB347', glyph: 'uparrow',
|
||||||
|
description: 'Usage spikes when stars sit',
|
||||||
|
propDNA: { reliable: ['points'], volatile: ['assists', 'rebounds'] },
|
||||||
|
education: 'Usage sponges soak up shots when a star sits or is injured. Their points props spike on cascade nights — read the injury report before trusting the baseline.',
|
||||||
|
},
|
||||||
|
'COMBO GUARD': {
|
||||||
|
tag: 'COMBO GD', sport: 'nba', color: '#00D4A0', glyph: 'twin',
|
||||||
|
description: 'Scoring + playmaking hybrid',
|
||||||
|
propDNA: { reliable: ['points', 'assists'], volatile: ['rebounds'] },
|
||||||
|
education: 'Combo guards score and create in equal measure, so points and assists both stay in play. Rebounds are the volatile leg for their size.',
|
||||||
|
},
|
||||||
|
'ROLE GLUE': {
|
||||||
|
tag: 'ROLE GLUE', sport: 'nba', color: '#9499A8', glyph: 'chain',
|
||||||
|
description: 'Low-usage specialist',
|
||||||
|
propDNA: { reliable: [], volatile: ['points', 'assists', 'rebounds'] },
|
||||||
|
education: 'Role glue players do the little things at low usage. Their counting props are thin and matchup-dependent — they reward unders more often than overs.',
|
||||||
|
},
|
||||||
|
'TRANSITION ENGINE': {
|
||||||
|
tag: 'TRANS ENG', sport: 'nba', color: '#22D3EE', glyph: 'chevrons',
|
||||||
|
description: 'Pace-pushing fast-break threat',
|
||||||
|
propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] },
|
||||||
|
education: 'Transition engines feast in the open floor. Their points correlate with game pace — target overs in projected up-tempo matchups.',
|
||||||
|
},
|
||||||
|
'POST SCORER': {
|
||||||
|
tag: 'POST SCORER', sport: 'nba', color: '#FF5C5C', glyph: 'postup',
|
||||||
|
description: 'Back-to-basket interior scorer',
|
||||||
|
propDNA: { reliable: ['points', 'rebounds'], volatile: ['threes', 'assists'] },
|
||||||
|
education: 'Post scorers operate in the paint. Points and rebounds are reliable against most fronts; perimeter props are noise.',
|
||||||
|
},
|
||||||
|
'DEFENSIVE SPECIALIST': {
|
||||||
|
tag: 'DEF SPEC', sport: 'nba', color: '#6366F1', glyph: 'shieldCheck',
|
||||||
|
description: 'Perimeter stopper, low usage',
|
||||||
|
propDNA: { reliable: ['steals'], volatile: ['points', 'assists'] },
|
||||||
|
education: 'Defensive specialists earn minutes with their on-ball defense. Steals and blocks carry their card; offensive props are low-volume and streaky.',
|
||||||
|
},
|
||||||
|
'POINT FORWARD': {
|
||||||
|
tag: 'PT FWD', sport: 'nba', color: '#38BDF8', glyph: 'half',
|
||||||
|
description: 'Oversized primary creator',
|
||||||
|
propDNA: { reliable: ['points', 'assists', 'rebounds'], volatile: ['threes'] },
|
||||||
|
education: 'Point forwards run the offense from a wing or big body, so points, assists, and rebounds all stay live. Their three-point output is the swing factor.',
|
||||||
|
},
|
||||||
|
SLASHER: {
|
||||||
|
tag: 'SLASHER', sport: 'nba', color: '#FB923C', glyph: 'slash',
|
||||||
|
description: 'Rim-attacking, foul-drawing driver',
|
||||||
|
propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] },
|
||||||
|
education: 'Slashers live at the rim and the free-throw line. Points are stable; their three-point props are volatile because they rarely settle for jumpers.',
|
||||||
|
},
|
||||||
|
'RIM RUNNER': {
|
||||||
|
tag: 'RIM RUN', sport: 'nba', color: '#F472B6', glyph: 'arc',
|
||||||
|
description: 'Lob and putback finisher',
|
||||||
|
propDNA: { reliable: ['rebounds'], volatile: ['points', 'assists'] },
|
||||||
|
education: 'Rim runners finish lobs and putbacks. Rebounds are reliable; their scoring depends entirely on feeds from creators.',
|
||||||
|
},
|
||||||
|
'3-AND-D': {
|
||||||
|
tag: '3&D', sport: 'nba', color: '#818CF8', glyph: 'crosshair',
|
||||||
|
description: 'Catch-and-shoot plus defense',
|
||||||
|
propDNA: { reliable: ['threes'], volatile: ['points', 'assists'] },
|
||||||
|
education: '3-and-D wings catch and shoot. Threes are their signature prop; total points swing with how many open looks the offense generates.',
|
||||||
|
},
|
||||||
|
'SIXTH MAN': {
|
||||||
|
tag: '6TH MAN', sport: 'nba', color: '#FACC15', glyph: 'bolt',
|
||||||
|
description: 'Bench scoring spark',
|
||||||
|
propDNA: { reliable: ['points'], volatile: ['rebounds', 'assists'] },
|
||||||
|
education: 'Sixth men provide instant offense off the bench. Their points props depend on minutes — confirm the rotation before betting overs.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────── WNBA-unique (5) ─────────
|
||||||
|
'POST FACILITATOR': {
|
||||||
|
tag: 'POST FAC', sport: 'wnba', color: '#C084FC', glyph: 'node',
|
||||||
|
description: 'Playmaking hub from the post',
|
||||||
|
propDNA: { reliable: ['assists', 'rebounds'], volatile: ['threes'] },
|
||||||
|
education: 'Post facilitators orchestrate from the elbow and block. Assists and rebounds are reliable; perimeter shooting is the volatile leg.',
|
||||||
|
},
|
||||||
|
'TWO-WAY WING': {
|
||||||
|
tag: 'TW WING', sport: 'wnba', color: '#A78BFA', glyph: 'shieldCheck',
|
||||||
|
description: 'Two-way perimeter wing',
|
||||||
|
propDNA: { reliable: ['points', 'steals'], volatile: ['assists'] },
|
||||||
|
education: 'Two-way wings contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.',
|
||||||
|
},
|
||||||
|
'STRETCH FORWARD': {
|
||||||
|
tag: 'STRETCH FWD', sport: 'wnba', color: '#2DD4BF', glyph: 'target',
|
||||||
|
description: 'Floor-spacing forward',
|
||||||
|
propDNA: { reliable: ['threes', 'points'], volatile: ['assists', 'blocks'] },
|
||||||
|
education: 'Stretch forwards space the floor from the four. Threes and points are reliable; interior props are matchup-dependent.',
|
||||||
|
},
|
||||||
|
'SLASHING GUARD': {
|
||||||
|
tag: 'SLASH GD', sport: 'wnba', color: '#FB923C', glyph: 'slash',
|
||||||
|
description: 'Downhill driving guard',
|
||||||
|
propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] },
|
||||||
|
education: 'Slashing guards attack downhill. Points are stable; three-point props are the volatile leg since they prioritize the rim.',
|
||||||
|
},
|
||||||
|
'INTERIOR ANCHOR': {
|
||||||
|
tag: 'INT ANCHOR', sport: 'wnba', color: '#6366F1', glyph: 'shield',
|
||||||
|
description: 'Paint defender and rebounder',
|
||||||
|
propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] },
|
||||||
|
education: 'Interior anchors own the paint. Rebounds and blocks are reliable; their scoring depends on post touches.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────── MLB (15) ─────────
|
||||||
|
'POWER PULL': {
|
||||||
|
tag: 'POWER PULL', sport: 'mlb', color: '#FF5C5C', glyph: 'batball',
|
||||||
|
description: 'HR-dependent, high strikeout power',
|
||||||
|
propDNA: { reliable: ['total_bases', 'home_runs'], volatile: ['hits'] },
|
||||||
|
education: 'Power-pull hitters live and die by the long ball. Total bases and home-run props carry their value; their batting-average-driven props (hits) are volatile from the strikeout risk.',
|
||||||
|
},
|
||||||
|
CONTACT: {
|
||||||
|
tag: 'CONTACT', sport: 'mlb', color: '#3DDC84', glyph: 'crosshair',
|
||||||
|
description: 'High average, low strikeout',
|
||||||
|
propDNA: { reliable: ['hits'], volatile: ['home_runs', 'total_bases'] },
|
||||||
|
education: 'Contact hitters rarely strike out, so their hits props are among the most reliable in baseball. Power props (HR, TB) are the volatile leg.',
|
||||||
|
},
|
||||||
|
'RUN PRODUCER': {
|
||||||
|
tag: 'RUN PROD', sport: 'mlb', color: '#4A9EFF', glyph: 'diamond',
|
||||||
|
description: 'RBI-dependent, lineup context',
|
||||||
|
propDNA: { reliable: ['rbi'], volatile: ['hits', 'home_runs'] },
|
||||||
|
education: 'Run producers hit in the heart of the order. RBI props track lineup context — strong with runners on base; their individual hit props are more variable.',
|
||||||
|
},
|
||||||
|
ACE: {
|
||||||
|
tag: 'ACE', sport: 'mlb', color: '#A78BFA', glyph: 'star',
|
||||||
|
description: 'High K/9, low WHIP, deep games',
|
||||||
|
propDNA: { reliable: ['strikeouts', 'innings_pitched'], volatile: ['earned_runs'] },
|
||||||
|
education: 'Aces miss bats and go deep. Strikeout and innings props are their most reliable; earned-run props are noisier because one swing can change a line.',
|
||||||
|
},
|
||||||
|
'BULLPEN ARM': {
|
||||||
|
tag: 'BULLPEN', sport: 'mlb', color: '#FFB347', glyph: 'bolt',
|
||||||
|
description: 'Short outings, high leverage',
|
||||||
|
propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] },
|
||||||
|
education: 'Bullpen arms throw short, high-leverage outings. Strikeout props can hit in one inning; innings and earned-run props are too small a sample to trust.',
|
||||||
|
},
|
||||||
|
'SPEED THREAT': {
|
||||||
|
tag: 'SPEED', sport: 'mlb', color: '#2DD4BF', glyph: 'chevrons',
|
||||||
|
description: 'Stolen bases, speed score',
|
||||||
|
propDNA: { reliable: ['stolen_bases', 'runs'], volatile: ['home_runs'] },
|
||||||
|
education: 'Speed threats turn singles into runs. Stolen-base and runs props are their lane; power props rarely clear.',
|
||||||
|
},
|
||||||
|
'TWO-WAY PLAYER': {
|
||||||
|
tag: 'TWO-WAY', sport: 'mlb', color: '#F472B6', glyph: 'half',
|
||||||
|
description: 'Bats and pitches at elite level',
|
||||||
|
propDNA: { reliable: ['total_bases', 'strikeouts'], volatile: ['hits'] },
|
||||||
|
education: 'Two-way players produce on both sides of the ball. Read which role they fill that day — their batting and pitching props live on different lines.',
|
||||||
|
},
|
||||||
|
'UTILITY PLAYER': {
|
||||||
|
tag: 'UTILITY', sport: 'mlb', color: '#22D3EE', glyph: 'plus',
|
||||||
|
description: 'Multi-position lineup flex',
|
||||||
|
propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] },
|
||||||
|
education: 'Utility players move around the lineup and the diamond. Their props are matchup- and slot-dependent — confirm they are starting before betting.',
|
||||||
|
},
|
||||||
|
'INNINGS EATER': {
|
||||||
|
tag: 'INN EATER', sport: 'mlb', color: '#818CF8', glyph: 'clock',
|
||||||
|
description: 'Durable, deep-start workhorse',
|
||||||
|
propDNA: { reliable: ['innings_pitched'], volatile: ['strikeouts', 'earned_runs'] },
|
||||||
|
education: 'Innings eaters pitch deep without elite stuff. Innings props are reliable; strikeout and earned-run props are more variable since they pitch to contact.',
|
||||||
|
},
|
||||||
|
'POWER SLUGGER': {
|
||||||
|
tag: 'SLUGGER', sport: 'mlb', color: '#FF6B4A', glyph: 'triangle',
|
||||||
|
description: 'All-fields power producer',
|
||||||
|
propDNA: { reliable: ['total_bases', 'rbi'], volatile: ['stolen_bases'] },
|
||||||
|
education: 'Power sluggers drive the ball to all fields. Total bases and RBI are reliable; speed props are not part of their game.',
|
||||||
|
},
|
||||||
|
'TABLE SETTER': {
|
||||||
|
tag: 'TABLE SET', sport: 'mlb', color: '#38BDF8', glyph: 'diamondLine',
|
||||||
|
description: 'On-base leadoff catalyst',
|
||||||
|
propDNA: { reliable: ['hits', 'runs'], volatile: ['rbi', 'home_runs'] },
|
||||||
|
education: 'Table setters get on base and score. Hits and runs props are their lane; RBI and power props sit lower in their profile.',
|
||||||
|
},
|
||||||
|
'GAP HITTER': {
|
||||||
|
tag: 'GAP', sport: 'mlb', color: '#34D399', glyph: 'uparrow',
|
||||||
|
description: 'Doubles and extra-base gaps',
|
||||||
|
propDNA: { reliable: ['total_bases', 'hits'], volatile: ['home_runs'] },
|
||||||
|
education: 'Gap hitters spray doubles. Total bases and hits are reliable; home-run props are the volatile leg of their extra-base profile.',
|
||||||
|
},
|
||||||
|
CLOSER: {
|
||||||
|
tag: 'CLOSER', sport: 'mlb', color: '#FB7185', glyph: 'lock',
|
||||||
|
description: 'Ninth-inning save specialist',
|
||||||
|
propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] },
|
||||||
|
education: 'Closers throw one high-leverage inning. Strikeout props can hit in a clean save; everything else is a one-inning coin flip.',
|
||||||
|
},
|
||||||
|
SWINGMAN: {
|
||||||
|
tag: 'SWINGMAN', sport: 'mlb', color: '#FBBF24', glyph: 'swap',
|
||||||
|
description: 'Spot starter and long relief',
|
||||||
|
propDNA: { reliable: [], volatile: ['strikeouts', 'innings_pitched', 'earned_runs'] },
|
||||||
|
education: 'Swingmen bounce between starting and relief. Their workload is unpredictable, so all of their props carry role risk — confirm the assignment.',
|
||||||
|
},
|
||||||
|
'DEFENSIVE WIZARD': {
|
||||||
|
tag: 'DEF WIZ', sport: 'mlb', color: '#6366F1', glyph: 'shieldCheck',
|
||||||
|
description: 'Glove-first defensive value',
|
||||||
|
propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] },
|
||||||
|
education: 'Defensive wizards earn their spot with the glove. Their offensive props are thin and bottom-of-the-order dependent — lean unders.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ───────── Soccer (6) — present in the design map ─────────
|
||||||
|
POACHER: {
|
||||||
|
tag: 'POACHER', sport: 'soccer', color: '#FF5C5C', glyph: 'crosshair',
|
||||||
|
description: 'Penalty-box finisher',
|
||||||
|
propDNA: { reliable: ['shots_on_target', 'goals'], volatile: ['assists'] },
|
||||||
|
education: 'Poachers finish inside the box. Shots-on-target and goals are their props; they rarely create for others.',
|
||||||
|
},
|
||||||
|
CREATOR: {
|
||||||
|
tag: 'CREATOR', sport: 'soccer', color: '#4A9EFF', glyph: 'node',
|
||||||
|
description: 'Chance-creating playmaker',
|
||||||
|
propDNA: { reliable: ['assists', 'passes'], volatile: ['goals'] },
|
||||||
|
education: 'Creators set the table. Assists and passing props are reliable; their goal output is the volatile leg.',
|
||||||
|
},
|
||||||
|
'TARGET MAN': {
|
||||||
|
tag: 'TARGET', sport: 'soccer', color: '#FF6B4A', glyph: 'triangle',
|
||||||
|
description: 'Hold-up aerial striker',
|
||||||
|
propDNA: { reliable: ['shots', 'shots_on_target'], volatile: ['goals', 'assists'] },
|
||||||
|
education: 'Target men win aerial duels and hold the ball up. Shot props are reliable; conversion to goals is variable.',
|
||||||
|
},
|
||||||
|
'BOX-TO-BOX': {
|
||||||
|
tag: 'B2B', sport: 'soccer', color: '#00D4A0', glyph: 'chevrons',
|
||||||
|
description: 'All-action central midfielder',
|
||||||
|
propDNA: { reliable: ['tackles', 'passes'], volatile: ['goals', 'shots'] },
|
||||||
|
education: 'Box-to-box midfielders cover every blade of grass. Tackles and passing props are reliable; their attacking output swings by role.',
|
||||||
|
},
|
||||||
|
'WING WIZARD': {
|
||||||
|
tag: 'WING', sport: 'soccer', color: '#2DD4BF', glyph: 'slash',
|
||||||
|
description: 'Dribbling wide threat',
|
||||||
|
propDNA: { reliable: ['shots', 'assists'], volatile: ['goals'] },
|
||||||
|
education: 'Wing wizards beat defenders wide. Shots and assists are their lane; goals come in streaks.',
|
||||||
|
},
|
||||||
|
'SWEEPER KEEPER': {
|
||||||
|
tag: 'SWEEPER', sport: 'soccer', color: '#A78BFA', glyph: 'shield',
|
||||||
|
description: 'Distributing goalkeeper',
|
||||||
|
propDNA: { reliable: ['saves', 'passes'], volatile: ['goals_conceded'] },
|
||||||
|
education: 'Sweeper keepers distribute and defend space. Saves and passing props are reliable; goals-conceded depends on the team in front of them.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NBA scorers — each returns a 0..1-ish weight from season averages.
|
||||||
|
* Inputs (all optional): ppg, rpg, apg, bpg, spg, threes (3PM/g), usg (%),
|
||||||
|
* fg3a (3PA/g), bench (bool), pos ('G'|'F'|'C').
|
||||||
|
*/
|
||||||
|
function scoreNBA(s) {
|
||||||
|
const ppg = num(s.ppg), rpg = num(s.rpg), apg = num(s.apg), bpg = num(s.bpg),
|
||||||
|
spg = num(s.spg), threes = num(s.threes), usg = num(s.usg), fg3a = num(s.fg3a);
|
||||||
|
const pos = (s.pos || '').toUpperCase();
|
||||||
|
const isBig = pos === 'C' || pos === 'F-C' || pos === 'C-F';
|
||||||
|
return {
|
||||||
|
'VOLUME SCORER': ppg >= 22 ? (ppg - 16) / 14 + (usg >= 28 ? 0.3 : 0) : 0,
|
||||||
|
'FLOOR GENERAL': apg >= 6 ? (apg - 3) / 7 : apg >= 4 ? 0.2 : 0,
|
||||||
|
'TWO-WAY ANCHOR': (bpg >= 1.3 ? bpg / 3 : 0) + (rpg >= 8 ? (rpg - 6) / 8 : 0),
|
||||||
|
'STRETCH BIG': isBig && threes >= 1.2 ? 0.5 + threes / 6 : 0,
|
||||||
|
'USAGE SPONGE': s.bench && usg >= 24 ? 0.5 : 0,
|
||||||
|
'COMBO GUARD': ppg >= 15 && apg >= 3 && apg < 7 ? 0.4 + apg / 20 : 0,
|
||||||
|
'ROLE GLUE': usg > 0 && usg < 16 && ppg < 10 ? 0.5 : 0,
|
||||||
|
'TRANSITION ENGINE': ppg >= 16 && spg >= 1.2 ? 0.3 : 0,
|
||||||
|
'POST SCORER': isBig && ppg >= 16 && threes < 1 ? 0.5 + ppg / 50 : 0,
|
||||||
|
'DEFENSIVE SPECIALIST': spg >= 1.4 && usg < 18 ? 0.5 + spg / 6 : 0,
|
||||||
|
'POINT FORWARD': apg >= 5 && (pos === 'F' || pos === 'G-F' || pos === 'F-G') && ppg >= 16 ? 0.6 + apg / 16 : 0,
|
||||||
|
SLASHER: ppg >= 16 && fg3a < 4 && !isBig ? 0.35 : 0,
|
||||||
|
'RIM RUNNER': isBig && rpg >= 7 && ppg < 16 && threes < 0.5 ? 0.45 : 0,
|
||||||
|
'3-AND-D': threes >= 1.6 && usg < 20 && spg >= 0.9 ? 0.5 + threes / 8 : 0,
|
||||||
|
'SIXTH MAN': s.bench && ppg >= 12 ? 0.4 + ppg / 40 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreWNBA(s) {
|
||||||
|
// WNBA reuses NBA archetypes plus 5 unique ones. Start from the NBA scores
|
||||||
|
// (filtered to WNBA-applicable) and add the unique forwards/guards.
|
||||||
|
const ppg = num(s.ppg), rpg = num(s.rpg), apg = num(s.apg), bpg = num(s.bpg),
|
||||||
|
spg = num(s.spg), threes = num(s.threes), usg = num(s.usg);
|
||||||
|
const pos = (s.pos || '').toUpperCase();
|
||||||
|
const isBig = pos === 'C' || pos === 'F' || pos === 'F-C';
|
||||||
|
return {
|
||||||
|
'VOLUME SCORER': ppg >= 18 ? (ppg - 12) / 12 + (usg >= 26 ? 0.25 : 0) : 0,
|
||||||
|
'FLOOR GENERAL': apg >= 5 ? (apg - 2) / 6 : 0,
|
||||||
|
'COMBO GUARD': ppg >= 14 && apg >= 3 && apg < 6 ? 0.4 + apg / 18 : 0,
|
||||||
|
'POST FACILITATOR': isBig && apg >= 3 && rpg >= 7 ? 0.6 + apg / 12 : 0,
|
||||||
|
'TWO-WAY WING': !isBig && ppg >= 12 && spg >= 1.2 ? 0.5 + spg / 6 : 0,
|
||||||
|
'STRETCH FORWARD': (pos === 'F' || isBig) && threes >= 1.2 ? 0.5 + threes / 5 : 0,
|
||||||
|
'SLASHING GUARD': pos.startsWith('G') && ppg >= 14 && threes < 1.5 ? 0.45 : 0,
|
||||||
|
'INTERIOR ANCHOR': isBig && (bpg >= 1 || rpg >= 8) ? 0.5 + rpg / 16 : 0,
|
||||||
|
'DEFENSIVE SPECIALIST': spg >= 1.5 && usg < 18 ? 0.45 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MLB scorers. Hitters and pitchers are disjoint; `isPitcher` (or presence of
|
||||||
|
* era/k9) routes to the pitcher archetypes.
|
||||||
|
* Hitter inputs: avg, hr, rbi, sb, ops, runs, k_rate, doubles.
|
||||||
|
* Pitcher inputs: era, k9, whip, ip_per_start, saves, role ('SP'|'RP'|'CL').
|
||||||
|
*/
|
||||||
|
function scoreMLB(s) {
|
||||||
|
const role = (s.role || '').toUpperCase();
|
||||||
|
const isPitcher = s.isPitcher || role === 'SP' || role === 'RP' || role === 'CL' ||
|
||||||
|
num(s.era) > 0 || num(s.k9) > 0;
|
||||||
|
if (isPitcher) {
|
||||||
|
const era = num(s.era), k9 = num(s.k9), whip = num(s.whip),
|
||||||
|
ip = num(s.ip_per_start), saves = num(s.saves);
|
||||||
|
return {
|
||||||
|
ACE: k9 >= 9.5 && era <= 3.6 && ip >= 5.5 ? 0.6 + k9 / 30 : k9 >= 9 ? 0.3 : 0,
|
||||||
|
'INNINGS EATER': ip >= 6 && k9 < 9 ? 0.55 + ip / 20 : 0,
|
||||||
|
CLOSER: role === 'CL' || saves >= 10 ? 0.7 + saves / 60 : 0,
|
||||||
|
'BULLPEN ARM': role === 'RP' && saves < 10 ? 0.55 : ip > 0 && ip < 3 ? 0.4 : 0,
|
||||||
|
SWINGMAN: role === 'SP' && ip < 5 && ip > 0 ? 0.4 : 0,
|
||||||
|
'TWO-WAY PLAYER': s.twoWay ? 0.8 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const avg = num(s.avg), hr = num(s.hr), rbi = num(s.rbi), sb = num(s.sb),
|
||||||
|
ops = num(s.ops), runs = num(s.runs), kRate = num(s.k_rate), doubles = num(s.doubles);
|
||||||
|
return {
|
||||||
|
'POWER PULL': hr >= 20 && kRate >= 24 ? 0.6 + hr / 60 : hr >= 18 ? 0.3 : 0,
|
||||||
|
'POWER SLUGGER': hr >= 20 && ops >= 0.85 && kRate < 24 ? 0.6 + hr / 60 : 0,
|
||||||
|
CONTACT: avg >= 0.28 && kRate < 16 ? 0.6 + (avg - 0.25) * 2 : avg >= 0.29 ? 0.4 : 0,
|
||||||
|
'RUN PRODUCER': rbi >= 50 && hr >= 12 ? 0.5 + rbi / 200 : 0,
|
||||||
|
'SPEED THREAT': sb >= 15 ? 0.6 + sb / 60 : sb >= 10 ? 0.35 : 0,
|
||||||
|
'TABLE SETTER': runs >= 50 && sb >= 8 && hr < 15 ? 0.5 + runs / 200 : 0,
|
||||||
|
'GAP HITTER': doubles >= 25 && hr < 20 ? 0.5 + doubles / 80 : 0,
|
||||||
|
'UTILITY PLAYER': s.utility ? 0.5 : 0,
|
||||||
|
'DEFENSIVE WIZARD': s.glove && avg < 0.25 ? 0.5 : 0,
|
||||||
|
'TWO-WAY PLAYER': s.twoWay ? 0.8 : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB };
|
||||||
|
|
||||||
|
/** Look up an archetype's static descriptor by name (case-insensitive). */
|
||||||
|
function getArchetype(name) {
|
||||||
|
if (!name) return null;
|
||||||
|
const key = String(name).toUpperCase();
|
||||||
|
const a = ARCHETYPES[key];
|
||||||
|
if (!a) return null;
|
||||||
|
return { name: key, ...a };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a player. Returns:
|
||||||
|
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
|
||||||
|
* primary/secondary are full descriptors (name, tag, color, glyph, description,
|
||||||
|
* propDNA, education). blend weights are normalized 0..1 over the top entries.
|
||||||
|
*/
|
||||||
|
function classify(sport, stats = {}) {
|
||||||
|
const sp = String(sport || 'nba').toLowerCase();
|
||||||
|
const scorer = SCORERS[sp];
|
||||||
|
if (!scorer) return { sport: sp, primary: null, secondary: null, blend: [] };
|
||||||
|
|
||||||
|
const scores = scorer(stats);
|
||||||
|
const ranked = Object.entries(scores)
|
||||||
|
.filter(([, v]) => v > 0)
|
||||||
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
if (ranked.length === 0) {
|
||||||
|
// Fallback so the UI always has something to render.
|
||||||
|
const fallback = sp === 'mlb' ? 'UTILITY PLAYER' : sp === 'wnba' ? 'TWO-WAY WING' : 'ROLE GLUE';
|
||||||
|
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = ranked.slice(0, 4);
|
||||||
|
const total = top.reduce((sum, [, v]) => sum + v, 0) || 1;
|
||||||
|
const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) }));
|
||||||
|
|
||||||
|
const primary = getArchetype(ranked[0][0]);
|
||||||
|
// Secondary only if it's a meaningful share (>= 40% of primary's score).
|
||||||
|
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
|
||||||
|
? getArchetype(ranked[1][0])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { sport: sp, primary, secondary, blend };
|
||||||
|
}
|
||||||
|
|
||||||
|
const classifyNBA = (stats) => classify('nba', stats);
|
||||||
|
const classifyWNBA = (stats) => classify('wnba', stats);
|
||||||
|
const classifyMLB = (stats) => classify('mlb', stats);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ARCHETYPES,
|
||||||
|
getArchetype,
|
||||||
|
classify,
|
||||||
|
classifyNBA,
|
||||||
|
classifyWNBA,
|
||||||
|
classifyMLB,
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* playerIntelService — aggregates a player's intelligence payload for the
|
||||||
|
* /player/:name profile page and the stats API (Session 42).
|
||||||
|
*
|
||||||
|
* Sources, all best-effort + graceful (the page must render even when every
|
||||||
|
* upstream is cold):
|
||||||
|
* - archetypeService.classify → PRIMARY/SECONDARY archetype + DNA blend
|
||||||
|
* - grades:{sport} cache → this player's graded props for tonight
|
||||||
|
* - caller-supplied season/last10/splits/gradeHistory (wired by the route
|
||||||
|
* from the stats adapters; empty when unavailable)
|
||||||
|
*
|
||||||
|
* Pure-ish: I/O is only the grades-cache read, and that's injectable for tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { classify } = require('./archetypeService');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a player-name URL param (Montgomery's note). Decode, strip anything
|
||||||
|
* that isn't a letter/number/space or name punctuation (. - '), collapse
|
||||||
|
* whitespace, cap length. Defends the cache key + any downstream lookups.
|
||||||
|
*/
|
||||||
|
function sanitizePlayerName(raw) {
|
||||||
|
let decoded = String(raw == null ? '' : raw);
|
||||||
|
try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
|
||||||
|
return decoded
|
||||||
|
.replace(/[^\p{L}\p{N}\s.'-]/gu, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
|
async function loadPlayerGrades(sport, name, cacheGetFn) {
|
||||||
|
const env = await cacheGetFn(`grades:${sport}`);
|
||||||
|
const grades = env && Array.isArray(env.grades) ? env.grades : [];
|
||||||
|
const target = normName(name);
|
||||||
|
if (!target) return [];
|
||||||
|
return grades.filter((g) => normName(g.player_name || g.player) === target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive the VYNDR Intelligence metric row from whatever we have. */
|
||||||
|
function buildIntel(stats, arch, propCount) {
|
||||||
|
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||||
|
// Form: lean on last-10 vs season if provided, else a neutral baseline that
|
||||||
|
// scales gently with the strongest graded prop's confidence proxy.
|
||||||
|
const form = stats.form != null ? clamp(Math.round(stats.form), 0, 100) : 70 + clamp(propCount * 4, 0, 22);
|
||||||
|
const usage = stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—';
|
||||||
|
return [
|
||||||
|
{ label: 'FORM', kind: 'form', value: String(form), score: `${clamp(form, 0, 100)}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' },
|
||||||
|
{ label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
|
||||||
|
{ label: 'MATCHUP', kind: 'grade', value: arch.primary ? gradeFromForm(form) : 'B', color: '#00D4A0' },
|
||||||
|
{ label: 'REST', kind: 'plain', value: stats.rest || '+0%', color: '#00D4A0' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function gradeFromForm(form) {
|
||||||
|
if (form >= 90) return 'A';
|
||||||
|
if (form >= 80) return 'B+';
|
||||||
|
if (form >= 70) return 'B';
|
||||||
|
return 'C';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full player intelligence payload.
|
||||||
|
* opts: { cacheGet, stats, season, last10, splits, gradeHistory, injury, team }
|
||||||
|
*/
|
||||||
|
async function getPlayerIntel(name, sport, opts = {}) {
|
||||||
|
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||||
|
const clean = sanitizePlayerName(name);
|
||||||
|
const sp = String(sport || 'nba').toLowerCase();
|
||||||
|
const stats = opts.stats || {};
|
||||||
|
|
||||||
|
const archetype = classify(sp, stats);
|
||||||
|
|
||||||
|
let props = [];
|
||||||
|
try {
|
||||||
|
props = await loadPlayerGrades(sp, clean, cacheGetFn);
|
||||||
|
} catch {
|
||||||
|
props = []; // cold/broken cache must not 500 the page
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeProps = props.map((p) => ({
|
||||||
|
stat: p.stat_type || p.stat,
|
||||||
|
line: p.line,
|
||||||
|
side: String(p.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
|
||||||
|
grade: p.grade,
|
||||||
|
confidence: p.confidence != null ? `${p.confidence}%` : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const team = (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
player: clean,
|
||||||
|
sport: sp,
|
||||||
|
team,
|
||||||
|
found: props.length > 0 || Object.keys(stats).length > 0,
|
||||||
|
archetype,
|
||||||
|
propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] },
|
||||||
|
education: archetype.primary ? archetype.primary.education : '',
|
||||||
|
season: opts.season || [],
|
||||||
|
last10: opts.last10 || [],
|
||||||
|
splits: opts.splits || [],
|
||||||
|
gradeHistory: opts.gradeHistory || [],
|
||||||
|
activeProps,
|
||||||
|
intel: buildIntel(stats, archetype, props.length),
|
||||||
|
injury: opts.injury || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonight's leaders for a sport — top graded props by confidence, optionally
|
||||||
|
* filtered to a single stat. Reads the same grades:{sport} cache.
|
||||||
|
*/
|
||||||
|
async function getLeaders(sport, opts = {}) {
|
||||||
|
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||||
|
const sp = String(sport || 'nba').toLowerCase();
|
||||||
|
const stat = opts.stat ? String(opts.stat).toLowerCase() : null;
|
||||||
|
const limit = Math.max(1, Math.min(50, Number(opts.limit) || 10));
|
||||||
|
|
||||||
|
let grades = [];
|
||||||
|
try {
|
||||||
|
const env = await cacheGetFn(`grades:${sp}`);
|
||||||
|
grades = env && Array.isArray(env.grades) ? env.grades : [];
|
||||||
|
} catch {
|
||||||
|
grades = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return grades
|
||||||
|
.filter((g) => !stat || String(g.stat_type || g.stat || '').toLowerCase() === stat)
|
||||||
|
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((g) => ({
|
||||||
|
player: g.player_name || g.player,
|
||||||
|
team: g.team || g.team_abbr || '',
|
||||||
|
stat: g.stat_type || g.stat,
|
||||||
|
line: g.line,
|
||||||
|
side: String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
|
||||||
|
grade: g.grade,
|
||||||
|
confidence: g.confidence,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sanitizePlayerName,
|
||||||
|
getPlayerIntel,
|
||||||
|
getLeaders,
|
||||||
|
_internals: { normName, loadPlayerGrades, buildIntel },
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Session 42 — ArchetypeBadge + ArchetypeBlend (frontend). Logic runs via the
|
||||||
|
// CommonJS lib; the .tsx is asserted as text (Phase-D–H pattern).
|
||||||
|
|
||||||
|
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');
|
||||||
|
const arch = require('../../web/src/lib/archetypes');
|
||||||
|
const svc = require('../../src/services/archetypeService');
|
||||||
|
|
||||||
|
describe('archetypes lib — badge styling', () => {
|
||||||
|
it('full variant fills with the archetype color + white text', () => {
|
||||||
|
const s = arch.badgeStyle('POWER PULL', 'full', 'md');
|
||||||
|
expect(s.bg).toBe('#FF5C5C');
|
||||||
|
expect(s.textColor).toBe('#FFFFFF');
|
||||||
|
expect(s.borderColor).toBe('#FF5C5C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ghost variant is transparent with a colored border', () => {
|
||||||
|
const s = arch.badgeStyle('FLOOR GENERAL', 'ghost', 'sm');
|
||||||
|
expect(s.bg).toBe('transparent');
|
||||||
|
expect(s.textColor).toBe('#4A9EFF');
|
||||||
|
expect(s.borderColor).toBe('#4A9EFFCC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tint variant (default) uses a low-alpha tinted background', () => {
|
||||||
|
const s = arch.badgeStyle('ACE');
|
||||||
|
expect(s.bg).toBe('#A78BFA1F');
|
||||||
|
expect(s.borderColor).toBe('#A78BFA52');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archetypeColor matches and is case-insensitive', () => {
|
||||||
|
expect(arch.archetypeColor('ace')).toBe('#A78BFA');
|
||||||
|
expect(arch.archetypeColor('Two-Way Anchor')).toBe('#A78BFA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('glyphSvg returns a 16x16 svg wrapper', () => {
|
||||||
|
const svg = arch.glyphSvg('star');
|
||||||
|
expect(svg).toContain('viewBox="0 0 16 16"');
|
||||||
|
expect(svg).toContain('<path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown archetype degrades to a neutral grey', () => {
|
||||||
|
expect(arch.archetypeColor('NOT REAL')).toBe('#9499A8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archetypes lib — colors agree with the backend service', () => {
|
||||||
|
it('every backend archetype color equals the frontend map color', () => {
|
||||||
|
for (const [name, a] of Object.entries(svc.ARCHETYPES)) {
|
||||||
|
expect(arch.archetypeColor(name)).toBe(a.color);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ArchetypeBadge.tsx', () => {
|
||||||
|
const src = read('components/vyndr/ArchetypeBadge.tsx');
|
||||||
|
it('renders the glyph via dangerouslySetInnerHTML and uses mono', () => {
|
||||||
|
expect(src).toContain('dangerouslySetInnerHTML');
|
||||||
|
expect(src).toContain('badgeStyle');
|
||||||
|
expect(src).toContain('className="mono"');
|
||||||
|
});
|
||||||
|
it('supports full / ghost / tint variants', () => {
|
||||||
|
expect(src).toContain("'full' | 'ghost' | 'tint'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ArchetypeBlend.tsx', () => {
|
||||||
|
const src = read('components/vyndr/ArchetypeBlend.tsx');
|
||||||
|
it('colors segments via archetypeColor and renders a PRIMARY legend', () => {
|
||||||
|
expect(src).toContain('archetypeColor');
|
||||||
|
expect(src).toContain('PRIMARY');
|
||||||
|
expect(src).toContain('ArchetypeBadge');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Session 42 — archetype classification service.
|
||||||
|
|
||||||
|
const svc = require('../../src/services/archetypeService');
|
||||||
|
|
||||||
|
describe('archetypeService — registry', () => {
|
||||||
|
it('exposes the full design archetype set (41: 15 NBA + 5 WNBA + 15 MLB + 6 soccer)', () => {
|
||||||
|
expect(Object.keys(svc.ARCHETYPES).length).toBe(41);
|
||||||
|
const bySport = {};
|
||||||
|
for (const a of Object.values(svc.ARCHETYPES)) bySport[a.sport] = (bySport[a.sport] || 0) + 1;
|
||||||
|
expect(bySport).toEqual({ nba: 15, wnba: 5, mlb: 15, soccer: 6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every archetype has name/tag/color/glyph/description/propDNA/education', () => {
|
||||||
|
for (const [name, a] of Object.entries(svc.ARCHETYPES)) {
|
||||||
|
expect(typeof a.tag).toBe('string');
|
||||||
|
expect(a.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
expect(typeof a.glyph).toBe('string');
|
||||||
|
expect(typeof a.description).toBe('string');
|
||||||
|
expect(a.propDNA).toBeTruthy();
|
||||||
|
expect(Array.isArray(a.propDNA.reliable)).toBe(true);
|
||||||
|
expect(Array.isArray(a.propDNA.volatile)).toBe(true);
|
||||||
|
expect(a.education.length).toBeGreaterThan(20);
|
||||||
|
expect(name).toBe(name.toUpperCase());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('colors are unique WITHIN each sport (reused across sports by design)', () => {
|
||||||
|
const bySport = {};
|
||||||
|
for (const a of Object.values(svc.ARCHETYPES)) {
|
||||||
|
bySport[a.sport] = bySport[a.sport] || [];
|
||||||
|
bySport[a.sport].push(a.color);
|
||||||
|
}
|
||||||
|
for (const [sport, colors] of Object.entries(bySport)) {
|
||||||
|
expect(new Set(colors).size).toBe(colors.length); // no dup within sport
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getArchetype is case-insensitive and returns null for unknown', () => {
|
||||||
|
expect(svc.getArchetype('ace').name).toBe('ACE');
|
||||||
|
expect(svc.getArchetype('Two-Way Anchor').name).toBe('TWO-WAY ANCHOR');
|
||||||
|
expect(svc.getArchetype('not a real one')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archetypeService — NBA classification', () => {
|
||||||
|
it('classifies a volume scorer', () => {
|
||||||
|
const r = svc.classifyNBA({ ppg: 30, rpg: 5, apg: 4, usg: 33, threes: 2.5, pos: 'G' });
|
||||||
|
expect(r.primary.name).toBe('VOLUME SCORER');
|
||||||
|
expect(r.sport).toBe('nba');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns primary + secondary for a hybrid (Point Forward / Floor General)', () => {
|
||||||
|
const r = svc.classifyNBA({ ppg: 22, rpg: 7, apg: 8, usg: 27, pos: 'F', threes: 1.5 });
|
||||||
|
expect(r.primary).toBeTruthy();
|
||||||
|
expect(r.secondary).toBeTruthy();
|
||||||
|
expect(r.primary.name).not.toBe(r.secondary.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a two-way anchor (Wembanyama-type)', () => {
|
||||||
|
const r = svc.classifyNBA({ ppg: 24, rpg: 11, apg: 3, bpg: 3.2, usg: 31, threes: 1.5, pos: 'C' });
|
||||||
|
expect(['TWO-WAY ANCHOR', 'POST SCORER', 'STRETCH BIG']).toContain(r.primary.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a normalized blend that sums to ~1', () => {
|
||||||
|
const r = svc.classifyNBA({ ppg: 28, rpg: 8, apg: 7, usg: 30, pos: 'F' });
|
||||||
|
const sum = r.blend.reduce((s, b) => s + b.weight, 0);
|
||||||
|
expect(sum).toBeGreaterThan(0.98);
|
||||||
|
expect(sum).toBeLessThan(1.02);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archetypeService — MLB classification', () => {
|
||||||
|
it('differentiates an Ace from an Innings Eater', () => {
|
||||||
|
const ace = svc.classifyMLB({ role: 'SP', era: 2.9, k9: 11.5, whip: 0.98, ip_per_start: 6.2 });
|
||||||
|
const eater = svc.classifyMLB({ role: 'SP', era: 4.1, k9: 7.0, whip: 1.3, ip_per_start: 6.5 });
|
||||||
|
expect(ace.primary.name).toBe('ACE');
|
||||||
|
expect(eater.primary.name).toBe('INNINGS EATER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a closer', () => {
|
||||||
|
const r = svc.classifyMLB({ role: 'CL', era: 2.2, k9: 12, saves: 24, ip_per_start: 1 });
|
||||||
|
expect(r.primary.name).toBe('CLOSER');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a contact hitter and a power-pull hitter differently', () => {
|
||||||
|
const contact = svc.classifyMLB({ avg: 0.315, hr: 8, rbi: 40, k_rate: 12, ops: 0.82 });
|
||||||
|
const power = svc.classifyMLB({ avg: 0.235, hr: 34, rbi: 88, k_rate: 30, ops: 0.86 });
|
||||||
|
expect(contact.primary.name).toBe('CONTACT');
|
||||||
|
expect(power.primary.name).toBe('POWER PULL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archetypeService — WNBA classification', () => {
|
||||||
|
it('classifies a dominant post / interior anchor (Wilson-type)', () => {
|
||||||
|
const r = svc.classifyWNBA({ ppg: 27, rpg: 12, apg: 2, bpg: 2.3, usg: 31, pos: 'F' });
|
||||||
|
expect(['INTERIOR ANCHOR', 'VOLUME SCORER', 'STRETCH FORWARD']).toContain(r.primary.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies a floor general', () => {
|
||||||
|
const r = svc.classifyWNBA({ ppg: 16, rpg: 4, apg: 7, usg: 24, pos: 'G' });
|
||||||
|
expect(r.primary.name).toBe('FLOOR GENERAL');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('archetypeService — graceful fallback', () => {
|
||||||
|
it('returns a fallback archetype for empty stats (never crashes)', () => {
|
||||||
|
expect(svc.classifyNBA({}).primary).toBeTruthy();
|
||||||
|
expect(svc.classifyMLB({}).primary).toBeTruthy();
|
||||||
|
expect(svc.classify('badsport', {}).primary).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Session 42 — Stats Explorer (/explore) page (bonus design section 07).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
|
const src = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('Stats Explorer page', () => {
|
||||||
|
it('consumes the real /api/stats/leaders endpoint', () => {
|
||||||
|
expect(src).toContain('/api/stats/leaders');
|
||||||
|
});
|
||||||
|
it('has NBA/MLB/WNBA sport tabs + a player search filter', () => {
|
||||||
|
expect(src).toContain('NBA');
|
||||||
|
expect(src).toContain('MLB');
|
||||||
|
expect(src).toContain('WNBA');
|
||||||
|
expect(src).toContain('Search players');
|
||||||
|
});
|
||||||
|
it('rows link to the player profile', () => {
|
||||||
|
expect(src).toContain('playerHref');
|
||||||
|
});
|
||||||
|
it('handles loading / error / empty states', () => {
|
||||||
|
expect(src).toContain("'loading'");
|
||||||
|
expect(src).toContain("'error'");
|
||||||
|
expect(src).toContain('No graded props');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Session 42 — Phase 4: grade-adapter intel fields + enhanced game card.
|
||||||
|
|
||||||
|
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');
|
||||||
|
const { mapScanToGradeResult, buildIntelFields } = require('../../web/src/lib/gradeAdapter');
|
||||||
|
|
||||||
|
describe('gradeAdapter — Player Intelligence fields', () => {
|
||||||
|
it('omits all new sections when the engine supplies nothing (self-hide)', () => {
|
||||||
|
const r = mapScanToGradeResult({ player: 'X', stat: 'points', line: 26.5, grade: 'A' });
|
||||||
|
expect(r.archetypeBlend).toBeUndefined();
|
||||||
|
expect(r.statContext).toBeUndefined();
|
||||||
|
expect(r.vyndrIntel).toBeUndefined();
|
||||||
|
expect(r.propDNA).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lifts a single archetype name into a one-segment blend', () => {
|
||||||
|
const f = buildIntelFields({ archetype: 'VOLUME SCORER' });
|
||||||
|
expect(f.archetypeBlend).toEqual([{ archetype: 'VOLUME SCORER', weight: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through a full blend + propDNA + statContext + vyndrIntel', () => {
|
||||||
|
const r = mapScanToGradeResult({
|
||||||
|
player: 'Wemby', stat: 'points', line: 26.5, grade: 'A',
|
||||||
|
archetype_blend: [{ archetype: 'TWO-WAY ANCHOR', weight: 0.6 }, { archetype: 'STRETCH BIG', weight: 0.4 }],
|
||||||
|
prop_dna: { reliable: ['points'], volatile: ['rebounds'] },
|
||||||
|
season_avg: 26.9, last10_avg: 28.4, vs_opp_avg: 30.1,
|
||||||
|
form: 92, usage: '31.2%', matchup_grade: 'A', rest: '+2.4%',
|
||||||
|
});
|
||||||
|
expect(r.archetypeBlend).toHaveLength(2);
|
||||||
|
expect(r.propDNA.reliable).toContain('points');
|
||||||
|
expect(r.statContext).toEqual({ season: '26.9', last10: '28.4', vsOpp: '30.1' });
|
||||||
|
expect(r.vyndrIntel).toEqual({ form: 92, usage: '31.2%', matchup: 'A', rest: '+2.4%' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GradeResultCard — new sections', () => {
|
||||||
|
const src = read('components/vyndr/GradeResultCard.tsx');
|
||||||
|
it('renders the archetype strip + STAT CONTEXT + VYNDR INTELLIGENCE', () => {
|
||||||
|
expect(src).toContain('ARCHETYPE STRIP');
|
||||||
|
expect(src).toContain('STAT CONTEXT');
|
||||||
|
expect(src).toContain('VYNDR INTELLIGENCE');
|
||||||
|
expect(src).toContain('ArchetypeBlend');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Enhanced GameCard (Session 42)', () => {
|
||||||
|
const src = read('components/vyndr/GameCard.tsx');
|
||||||
|
it('renders MLB starting pitchers when provided', () => {
|
||||||
|
expect(src).toContain('g.pitchers');
|
||||||
|
expect(src).toContain('STARTING');
|
||||||
|
expect(src).toContain('ERA');
|
||||||
|
});
|
||||||
|
it('prefers the player-grouped StatStrip (name once) over per-prop rows', () => {
|
||||||
|
expect(src).toContain('g.playerStrips');
|
||||||
|
expect(src).toContain('StatStrip');
|
||||||
|
expect(src).toContain('variant="compact"');
|
||||||
|
});
|
||||||
|
it('links player names to their profile', () => {
|
||||||
|
expect(src).toContain('playerHref');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// Session 42 — player intelligence aggregation. cacheGet is injected so these
|
||||||
|
// run pure (no Redis, no HTTP, no rate limiter).
|
||||||
|
|
||||||
|
const svc = require('../../src/services/playerIntelService');
|
||||||
|
|
||||||
|
const cacheWith = (envelope) => async (key) => (key.startsWith('grades:') ? envelope : null);
|
||||||
|
|
||||||
|
describe('sanitizePlayerName', () => {
|
||||||
|
it('decodes URL encoding and keeps name punctuation', () => {
|
||||||
|
expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic');
|
||||||
|
expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox");
|
||||||
|
expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips injection / control characters', () => {
|
||||||
|
expect(svc.sanitizePlayerName('Luka<script>')).toBe('Lukascript');
|
||||||
|
expect(svc.sanitizePlayerName('a/../../etc/passwd')).toBe('a....etcpasswd');
|
||||||
|
expect(svc.sanitizePlayerName('x'.repeat(200)).length).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed percent-encoding without throwing', () => {
|
||||||
|
expect(() => svc.sanitizePlayerName('%E0%A4%A')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPlayerIntel', () => {
|
||||||
|
it('returns archetype + intelligence + props, found=true when player has grades', async () => {
|
||||||
|
const envelope = {
|
||||||
|
grades: [
|
||||||
|
{ player: 'Austin Riley', team: 'ATL', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'B+', confidence: 71 },
|
||||||
|
{ player: 'Austin Riley', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'C', confidence: 60 },
|
||||||
|
{ player: 'Someone Else', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 80 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const r = await svc.getPlayerIntel('Austin Riley', 'mlb', {
|
||||||
|
cacheGet: cacheWith(envelope),
|
||||||
|
stats: { avg: 0.282, hr: 18, rbi: 54, ops: 0.845, k_rate: 26 },
|
||||||
|
});
|
||||||
|
expect(r.player).toBe('Austin Riley');
|
||||||
|
expect(r.sport).toBe('mlb');
|
||||||
|
expect(r.team).toBe('ATL');
|
||||||
|
expect(r.found).toBe(true);
|
||||||
|
expect(r.archetype.primary).toBeTruthy();
|
||||||
|
expect(r.activeProps).toHaveLength(2); // only Riley's two props
|
||||||
|
expect(r.activeProps[0]).toMatchObject({ stat: 'total_bases', side: 'O', grade: 'B+' });
|
||||||
|
expect(r.propDNA.reliable.length + r.propDNA.volatile.length).toBeGreaterThan(0);
|
||||||
|
expect(r.education.length).toBeGreaterThan(0);
|
||||||
|
expect(r.intel.find((m) => m.label === 'FORM')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades gracefully when the grades cache is cold (found=false, no crash)', async () => {
|
||||||
|
const r = await svc.getPlayerIntel('Nobody Special', 'nba', { cacheGet: async () => null });
|
||||||
|
expect(r.found).toBe(false);
|
||||||
|
expect(r.activeProps).toEqual([]);
|
||||||
|
expect(r.archetype.primary).toBeTruthy(); // fallback archetype
|
||||||
|
expect(Array.isArray(r.intel)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a throwing cache (returns a valid payload)', async () => {
|
||||||
|
const r = await svc.getPlayerIntel('X', 'nba', { cacheGet: async () => { throw new Error('redis down'); } });
|
||||||
|
expect(r.activeProps).toEqual([]);
|
||||||
|
expect(r.player).toBe('X');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeaders', () => {
|
||||||
|
const envelope = {
|
||||||
|
grades: [
|
||||||
|
{ player: 'A', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'A', confidence: 88 },
|
||||||
|
{ player: 'B', stat_type: 'hits', line: 1.5, direction: 'over', grade: 'B', confidence: 72 },
|
||||||
|
{ player: 'C', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 95 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns top props by confidence', async () => {
|
||||||
|
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), limit: 2 });
|
||||||
|
expect(r).toHaveLength(2);
|
||||||
|
expect(r[0].player).toBe('C'); // highest confidence
|
||||||
|
expect(r[0].confidence).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters to a single stat when given', async () => {
|
||||||
|
const r = await svc.getLeaders('mlb', { cacheGet: cacheWith(envelope), stat: 'hits' });
|
||||||
|
expect(r).toHaveLength(2);
|
||||||
|
expect(r.every((x) => x.stat === 'hits')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] on a cold cache', async () => {
|
||||||
|
expect(await svc.getLeaders('nba', { cacheGet: async () => null })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Session 42 — player profile adapter logic + page/proxy/route source assertions.
|
||||||
|
|
||||||
|
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');
|
||||||
|
const adapter = require('../../web/src/lib/playerProfileAdapter');
|
||||||
|
const { playerHref } = require('../../web/src/lib/playerHref');
|
||||||
|
|
||||||
|
describe('playerProfileAdapter', () => {
|
||||||
|
it('builds two-letter initials', () => {
|
||||||
|
expect(adapter.initials('Victor Wembanyama')).toBe('VW');
|
||||||
|
expect(adapter.initials('Giannis')).toBe('GI');
|
||||||
|
expect(adapter.initials('')).toBe('??');
|
||||||
|
});
|
||||||
|
it('maps sport labels', () => {
|
||||||
|
expect(adapter.sportLabel('nba')).toBe('NBA');
|
||||||
|
expect(adapter.sportLabel('soccer')).toBe('SOC');
|
||||||
|
});
|
||||||
|
it('flattens prop DNA reliable-first with colors', () => {
|
||||||
|
const rows = adapter.dnaRows({ reliable: ['total_bases'], volatile: ['hits'] });
|
||||||
|
expect(rows[0]).toMatchObject({ prop: 'Total Bases', state: 'RELIABLE', color: '#00D4A0' });
|
||||||
|
expect(rows[1]).toMatchObject({ prop: 'Hits', state: 'VOLATILE', color: '#FFB347' });
|
||||||
|
});
|
||||||
|
it('writes a blend readout naming primary + secondary', () => {
|
||||||
|
const txt = adapter.blendReadout({ primary: { name: 'POWER PULL' }, secondary: { name: 'RUN PRODUCER' } });
|
||||||
|
expect(txt).toContain('Power Pull');
|
||||||
|
expect(txt).toContain('Run Producer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('playerHref', () => {
|
||||||
|
it('encodes name + sport', () => {
|
||||||
|
expect(playerHref('De\'Aaron Fox', 'nba')).toBe("/player/De'Aaron%20Fox?sport=nba");
|
||||||
|
expect(playerHref('Riley', 'MLB')).toBe('/player/Riley?sport=mlb');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('player profile page', () => {
|
||||||
|
const src = read('app/player/[name]/page.tsx');
|
||||||
|
it('fetches the stats API and renders the 9 design sections', () => {
|
||||||
|
expect(src).toContain('/api/stats/player/');
|
||||||
|
expect(src).toContain('ARCHETYPE DNA');
|
||||||
|
expect(src).toContain('PROP DNA');
|
||||||
|
expect(src).toContain('VYNDR INTELLIGENCE');
|
||||||
|
expect(src).toContain('ACTIVE PROPS');
|
||||||
|
expect(src).toContain('SEASON STATS');
|
||||||
|
expect(src).toContain('GRADE HISTORY');
|
||||||
|
expect(src).toContain('SPLITS');
|
||||||
|
});
|
||||||
|
it('handles loading + error states (never crashes on missing data)', () => {
|
||||||
|
expect(src).toContain("'loading'");
|
||||||
|
expect(src).toContain("'error'");
|
||||||
|
expect(src).toContain('Could not load');
|
||||||
|
});
|
||||||
|
it('uses ArchetypeBlend + SportBadge + GradeBadge', () => {
|
||||||
|
expect(src).toContain('ArchetypeBlend');
|
||||||
|
expect(src).toContain('SportBadge');
|
||||||
|
expect(src).toContain('GradeBadge');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stats API proxy routes', () => {
|
||||||
|
it('player proxy forwards to the Express stats route', () => {
|
||||||
|
const src = read('app/api/stats/player/[name]/route.ts');
|
||||||
|
expect(src).toContain('/api/stats/player/');
|
||||||
|
expect(src).toContain('BACKEND_URL');
|
||||||
|
});
|
||||||
|
it('leaders proxy forwards to the Express stats route', () => {
|
||||||
|
const src = read('app/api/stats/leaders/route.ts');
|
||||||
|
expect(src).toContain('/api/stats/leaders');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GradeResultCard player-name link (Session 42)', () => {
|
||||||
|
it('links the header player name to the profile', () => {
|
||||||
|
const src = read('components/vyndr/GradeResultCard.tsx');
|
||||||
|
expect(src).toContain('playerHref(d.player');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,10 +24,12 @@ describe('Session 41 — backend MLB stat_type whitelist', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Session 41 — broken-route redirects', () => {
|
describe('Session 41 — broken-route redirects', () => {
|
||||||
it('/settings redirects to /profile', () => {
|
// Session 42 — /settings is now a real settings page (replaced the S41
|
||||||
|
// redirect). The 404 it fixed is still fixed; the route just renders content.
|
||||||
|
it('/settings is a real page (no longer a redirect stub)', () => {
|
||||||
const src = read('app/settings/page.tsx');
|
const src = read('app/settings/page.tsx');
|
||||||
expect(src).toContain("from 'next/navigation'");
|
expect(src).not.toContain("redirect('/profile')");
|
||||||
expect(src).toContain("redirect('/profile')");
|
expect(src).toContain('DANGER ZONE');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/report redirects to /blog (THE REPORT link target)', () => {
|
it('/report redirects to /blog (THE REPORT link target)', () => {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Session 42 — Settings page + BookChip.
|
||||||
|
|
||||||
|
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');
|
||||||
|
const { bookInfo } = require('../../web/src/lib/books');
|
||||||
|
|
||||||
|
describe('books lib (BookChip data)', () => {
|
||||||
|
it('maps known books to brand colors (case-insensitive aliases)', () => {
|
||||||
|
expect(bookInfo('DK')).toMatchObject({ name: 'DraftKings', fg: '#53D337' });
|
||||||
|
expect(bookInfo('draftkings').mono).toBe('DK');
|
||||||
|
expect(bookInfo('ESPN').mono).toBe('EB');
|
||||||
|
});
|
||||||
|
it('degrades unknown books to a neutral chip', () => {
|
||||||
|
const b = bookInfo('ZZZ');
|
||||||
|
expect(b.mono).toBe('ZZZ');
|
||||||
|
expect(b.fg).toBe('#B8BCC8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings page', () => {
|
||||||
|
const src = read('app/settings/page.tsx');
|
||||||
|
|
||||||
|
it('is a real page now (not a redirect)', () => {
|
||||||
|
expect(src).not.toContain("redirect('/profile')");
|
||||||
|
expect(src).toContain("'use client'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the design sections', () => {
|
||||||
|
for (const label of ['ACCOUNT', 'SUBSCRIPTION', 'NOTIFICATIONS', 'DISPLAY PREFERENCES', 'RESPONSIBLE PLAY', 'DANGER ZONE']) {
|
||||||
|
expect(src).toContain(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the plan tier from useAuth (consistent with nav/profile)', () => {
|
||||||
|
expect(src).toContain('useAuth()');
|
||||||
|
expect(src).toContain('tierLabel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('LINKS to /settings/security (does NOT replace the MFA page)', () => {
|
||||||
|
expect(src).toContain('href="/settings/security"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('danger zone delete button is gated on typing DELETE exactly', () => {
|
||||||
|
expect(src).toContain("deleteText === 'DELETE'");
|
||||||
|
expect(src).toContain('disabled={!canDelete');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the existing Preferences modal via window.__prefs', () => {
|
||||||
|
expect(src).toContain('window.__prefs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fake account-deletion success', () => {
|
||||||
|
// On a non-ok response we surface an honest message, not a success.
|
||||||
|
expect(src).toContain('could not be completed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BookChip.tsx', () => {
|
||||||
|
const src = read('components/vyndr/BookChip.tsx');
|
||||||
|
it('renders a mono tile from the books lib', () => {
|
||||||
|
expect(src).toContain('bookInfo');
|
||||||
|
expect(src).toContain('className="mono"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Session 42 — StatStrip (.tsx asserted as text; the design's hard rules are
|
||||||
|
// structural so they're verifiable in source).
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||||
|
const src = fs.readFileSync(path.join(WEB, 'components', 'vyndr', 'StatStrip.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('StatStrip.tsx', () => {
|
||||||
|
it('renders the player name ONCE (single PlayerName helper, not per-stat)', () => {
|
||||||
|
// PlayerName is defined once and the stats map never references `player`.
|
||||||
|
const playerNameUses = (src.match(/<PlayerName/g) || []).length;
|
||||||
|
expect(playerNameUses).toBeGreaterThan(0);
|
||||||
|
// the stat row maps over `stats`, not the player name
|
||||||
|
expect(src).toMatch(/stats\.map/);
|
||||||
|
expect(src).not.toMatch(/stats\.map[\s\S]{0,80}\{player\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays stats out horizontally with mono + separators (no vertical repetition)', () => {
|
||||||
|
expect(src).toContain('className="mono"');
|
||||||
|
expect(src).toContain('const Sep =');
|
||||||
|
expect(src).toMatch(/flexWrap: 'wrap'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders props inline with GradeBadge', () => {
|
||||||
|
expect(src).toContain('GradeBadge');
|
||||||
|
expect(src).toMatch(/props\.map/);
|
||||||
|
expect(src).toContain('PROPS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expanded variant shows archetype badge(s)', () => {
|
||||||
|
expect(src).toContain("variant === 'expanded'");
|
||||||
|
expect(src).toContain('ArchetypeBadge');
|
||||||
|
expect(src).toContain('showDesc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports an onPlayerClick navigation hook (keyboard accessible)', () => {
|
||||||
|
expect(src).toContain('onPlayerClick');
|
||||||
|
expect(src).toContain('onKeyDown');
|
||||||
|
expect(src).toContain("role=\"link\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compact variant supports primary + secondary archetypes', () => {
|
||||||
|
expect(src).toContain('archetype.primary');
|
||||||
|
expect(src).toContain('archetype?.secondary');
|
||||||
|
expect(src).toContain("variant=\"ghost\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stat leaders proxy (Session 42). Forwards GET /api/stats/leaders to Express,
|
||||||
|
* preserving ?sport=&stat=&limit=. Powers the Terminal / Stats Explorer.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const qs = req.nextUrl.search;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/stats/leaders${qs}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await upstream.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Stats service is unreachable. Try again in a moment.' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player intelligence proxy (Session 42). Forwards GET /api/stats/player/:name
|
||||||
|
* to the Express stats route (which sanitizes the name + aggregates archetype /
|
||||||
|
* props / VYNDR intelligence). Thin pass-through; preserves ?sport=.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ name: string }> }) {
|
||||||
|
const { name } = await ctx.params;
|
||||||
|
const qs = req.nextUrl.search;
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(`${BACKEND_URL}/api/stats/player/${encodeURIComponent(name)}${qs}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
const data = await upstream.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Player service is unreachable. Try again in a moment.' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import SportBadge from '@/components/vyndr/SportBadge';
|
||||||
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
|
import { playerHref } from '@/lib/playerHref';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stats Explorer (/explore) — Session 42, design section 07. The data hub:
|
||||||
|
* tonight's league leaderboard (top graded props by confidence) with grade +
|
||||||
|
* archetype context. Consumes the real /api/stats/leaders endpoint. Sub-panels
|
||||||
|
* (hit-rate trends, head-to-head, market-vs-VYNDR) need historical data and are
|
||||||
|
* a Session-43 follow-up — see BUILD-STATE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Leader {
|
||||||
|
player: string;
|
||||||
|
team: string;
|
||||||
|
stat: string;
|
||||||
|
line: number | string;
|
||||||
|
side: string;
|
||||||
|
grade: string;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORTS = [
|
||||||
|
{ key: 'nba', label: 'NBA' },
|
||||||
|
{ key: 'mlb', label: 'MLB' },
|
||||||
|
{ key: 'wnba', label: 'WNBA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ExplorePage() {
|
||||||
|
const [sport, setSport] = useState('nba');
|
||||||
|
const [leaders, setLeaders] = useState<Leader[]>([]);
|
||||||
|
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setState('loading');
|
||||||
|
fetch(`/api/stats/leaders?sport=${sport}&limit=25`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => { if (active) { setLeaders(Array.isArray(d.leaders) ? d.leaders : []); setState('ready'); } })
|
||||||
|
.catch(() => { if (active) setState('error'); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [sport]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return q ? leaders.filter((l) => (l.player || '').toLowerCase().includes(q)) : leaders;
|
||||||
|
}, [leaders, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 920, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 14, marginBottom: 22, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 8 }}>STATS · /EXPLORE</div>
|
||||||
|
<h1 style={{ margin: '0 0 8px', fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Stats Explorer</h1>
|
||||||
|
<p style={{ margin: 0, maxWidth: 560, fontSize: 14, lineHeight: 1.6, color: 'var(--text-1)' }}>
|
||||||
|
Tonight's league leaderboard — every graded prop, ranked by VYNDR confidence, with the grade and archetype context only VYNDR has.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 8, padding: 4 }}>
|
||||||
|
{SPORTS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
onClick={() => setSport(s.key)}
|
||||||
|
className="mono"
|
||||||
|
style={{ cursor: 'pointer', border: 'none', borderRadius: 5, padding: '7px 14px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: sport === s.key ? '#06060B' : 'var(--text-1)', background: sport === s.key ? 'var(--g-a)' : 'transparent' }}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FILTER BAR */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 11, flexWrap: 'wrap', marginBottom: 14, background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '11px 14px' }}>
|
||||||
|
<span className="mono" style={{ fontSize: 14, color: 'var(--text-2)' }}>⌕</span>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search players"
|
||||||
|
style={{ appearance: 'none', background: 'transparent', border: 'none', outline: 'none', fontFamily: 'var(--sans)', fontSize: 13, color: '#fff', flex: 1, minWidth: 140 }}
|
||||||
|
/>
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>{rows.length} graded</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LEADERBOARD */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 13 }}>
|
||||||
|
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--text-1)' }}>LEAGUE LEADERBOARD</span>
|
||||||
|
<SportBadge sport={sport} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden' }}>
|
||||||
|
<div className="mono game-lines-grid" style={{ display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '10px 16px', borderBottom: '1px solid #14141E', fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.08em' }}>
|
||||||
|
<div>#</div><div>PLAYER</div><div style={{ textAlign: 'right' }}>PROP</div><div style={{ textAlign: 'right' }}>CONF</div><div style={{ textAlign: 'center' }}>GRD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state === 'loading' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>Loading tonight's slate…</div>}
|
||||||
|
{state === 'error' && <div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--miss)' }}>Could not load the leaderboard. Try again.</div>}
|
||||||
|
{state === 'ready' && rows.length === 0 && (
|
||||||
|
<div className="mono" style={{ padding: '30px 16px', textAlign: 'center', fontSize: 12, color: 'var(--text-2)' }}>
|
||||||
|
No graded props for {sport.toUpperCase()} yet — check back when tonight's slate posts.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state === 'ready' && rows.map((r, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={playerHref(r.player, sport)}
|
||||||
|
className="game-lines-grid"
|
||||||
|
style={{ textDecoration: 'none', color: 'inherit', display: 'grid', gridTemplateColumns: '34px 1fr 90px 70px 44px', gap: 0, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid #14141E' }}
|
||||||
|
>
|
||||||
|
<div className="mono" style={{ fontSize: 14, fontWeight: 700, color: i < 3 ? 'var(--g-a)' : 'var(--text-1)' }}>{i + 1}</div>
|
||||||
|
<div style={{ minWidth: 0, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 13, color: '#fff', whiteSpace: 'nowrap' }}>{r.player}</span>
|
||||||
|
{r.team && <span className="mono" style={{ fontSize: 10, color: 'var(--text-1)' }}>{r.team}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ textAlign: 'right', fontSize: 12, color: '#C8CCD6' }}>{r.stat} {r.side}{r.line}</div>
|
||||||
|
<div className="mono" style={{ textAlign: 'right', fontSize: 13, fontWeight: 600, color: 'var(--text-0)' }}>{r.confidence != null ? `${r.confidence}%` : '—'}</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}><GradeBadge grade={r.grade} size="sm" /></div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import SportBadge from '@/components/vyndr/SportBadge';
|
||||||
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
|
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||||
|
import { initials, sportLabel, dnaRows, blendReadout } from '@/lib/playerProfileAdapter';
|
||||||
|
|
||||||
|
interface IntelMetric { label: string; kind: string; value: string; score?: string; color: string }
|
||||||
|
interface ActiveProp { stat: string; line: number | string; side: string; grade: string; confidence?: string | null }
|
||||||
|
interface PlayerIntel {
|
||||||
|
player: string;
|
||||||
|
sport: string;
|
||||||
|
team: string;
|
||||||
|
found: boolean;
|
||||||
|
archetype: { primary: { name: string } | null; secondary: { name: string } | null; blend: { archetype: string; weight: number }[] };
|
||||||
|
propDNA: { reliable: string[]; volatile: string[] };
|
||||||
|
education: string;
|
||||||
|
season: { k: string; v: string; lg?: string }[];
|
||||||
|
last10: { d?: string; opp?: string; res?: string; stat?: string }[];
|
||||||
|
splits: { k: string; a: string; b: string }[];
|
||||||
|
gradeHistory: { grade: string; prop: string; hit?: boolean; miss?: boolean }[];
|
||||||
|
activeProps: ActiveProp[];
|
||||||
|
intel: IntelMetric[];
|
||||||
|
injury: { label: string; note: string; cascade: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectionLabel = ({ children, color = '#7A7E8C', dot = '#00D4A0' }: { children: React.ReactNode; color?: string; dot?: string }) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 14 }}>
|
||||||
|
<span style={{ width: 6, height: 6, background: dot, borderRadius: 1 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color }}>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function PlayerProfilePage() {
|
||||||
|
const params = useParams();
|
||||||
|
const search = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const rawName = decodeURIComponent(String(params?.name || ''));
|
||||||
|
const sport = (search.get('sport') || 'nba').toLowerCase();
|
||||||
|
|
||||||
|
const [data, setData] = useState<PlayerIntel | null>(null);
|
||||||
|
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
||||||
|
const [dnaOpen, setDnaOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setState('loading');
|
||||||
|
fetch(`/api/stats/player/${encodeURIComponent(rawName)}?sport=${encodeURIComponent(sport)}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => { if (active) { setData(d); setState('ready'); } })
|
||||||
|
.catch(() => { if (active) setState('error'); });
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [rawName, sport]);
|
||||||
|
|
||||||
|
if (state === 'loading') {
|
||||||
|
return (
|
||||||
|
<section style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<p className="mono" style={{ color: 'var(--text-2)' }}>Loading player intelligence…</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'error' || !data) {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 600, margin: '0 auto', padding: '40px 16px' }}>
|
||||||
|
<p className="mono" style={{ color: 'var(--miss)' }}>Could not load this player. Try again in a moment.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = data;
|
||||||
|
const dna = dnaRows(p.propDNA);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 920, margin: '0 auto', padding: '20px 16px 120px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 20, flexWrap: 'wrap' }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em' }}>PLAYER · /{p.sport.toUpperCase()}</div>
|
||||||
|
<button onClick={() => router.back()} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 6, padding: '6px 12px', fontSize: 11, color: 'var(--text-1)' }}>← Back</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* A. HERO */}
|
||||||
|
<div style={{ position: 'relative', overflow: 'hidden', background: 'linear-gradient(160deg,#15151f,#08080F)', border: '1px solid var(--border)', borderRadius: 16, padding: 26, marginBottom: 14 }}>
|
||||||
|
<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 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 }}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.015em', lineHeight: 1.05 }}>{p.player}</h1>
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<SportBadge sport={p.sport} />
|
||||||
|
{p.team && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{p.team}</span>}
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-2)' }}>{sportLabel(p.sport)}</span>
|
||||||
|
</div>
|
||||||
|
{p.archetype?.blend?.length > 0 && (
|
||||||
|
<div style={{ marginTop: 15 }}>
|
||||||
|
<div className="mono" style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 9 }}>ARCHETYPE DNA</div>
|
||||||
|
<ArchetypeBlend blend={p.archetype.blend} size="md" showLegend caption={blendReadout(p.archetype)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.season?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ position: 'relative', height: 1, background: 'var(--border)', margin: '20px 0 16px' }} />
|
||||||
|
<div className="mono" style={{ position: 'relative', display: 'flex', gap: 28, flexWrap: 'wrap' }}>
|
||||||
|
{p.season.map((s, i) => (
|
||||||
|
<div key={i}><span style={{ fontSize: 20, color: '#fff', fontWeight: 600 }}>{s.v}</span> <span style={{ fontSize: 11, color: 'var(--text-1)' }}>{s.k}</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* I. INJURY */}
|
||||||
|
{p.injury && (
|
||||||
|
<div style={{ display: 'flex', gap: 13, alignItems: 'flex-start', background: 'linear-gradient(135deg,#1A1305,#0E0B05)', border: '1px solid #FFB34740', borderRadius: 12, padding: '15px 17px', marginBottom: 14 }}>
|
||||||
|
<span style={{ flex: 'none', fontSize: 16 }}>⚠️</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<span className="mono" style={{ fontWeight: 700, fontSize: 12, color: 'var(--amber)', letterSpacing: '0.06em' }}>{p.injury.label}</span>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>{p.injury.note}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ marginTop: 7, fontSize: 12, color: 'var(--text-1)' }}><span style={{ color: 'var(--amber)' }}>CASCADE</span> · {p.injury.cascade}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* B. PROP DNA */}
|
||||||
|
{dna.length > 0 && (
|
||||||
|
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
||||||
|
<SectionLabel color="var(--g-a)">PROP DNA</SectionLabel>
|
||||||
|
<p style={{ margin: '0 0 16px', fontSize: 12, color: '#7E8A86', maxWidth: 520 }}>Which props this archetype makes reliable vs. volatile — unique to VYNDR.</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 10 }} className="terminal-grid">
|
||||||
|
{dna.map((d, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, background: 'rgba(0,0,0,0.25)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 9, padding: '11px 13px' }}>
|
||||||
|
<span style={{ flex: 'none', width: 9, height: 9, borderRadius: '50%', background: d.color, boxShadow: `0 0 8px ${d.color}` }} />
|
||||||
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-0)' }}>{d.prop}</span>
|
||||||
|
<span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 600, letterSpacing: '0.06em', color: d.color }}>{d.state}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{p.education && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setDnaOpen((o) => !o)} className="mono" style={{ marginTop: 14, cursor: 'pointer', background: 'transparent', border: 'none', padding: 0, fontSize: 11, fontWeight: 600, color: 'var(--g-a)', letterSpacing: '0.04em' }}>What does this mean? ⌄</button>
|
||||||
|
{dnaOpen && <p style={{ margin: '12px 0 0', paddingTop: 13, borderTop: '1px solid rgba(0,212,160,0.16)', fontSize: 13, lineHeight: 1.65, color: '#A8B2AE', maxWidth: 640 }}>{p.education}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* C. VYNDR INTELLIGENCE */}
|
||||||
|
{p.intel?.length > 0 && (
|
||||||
|
<div className="intel-surface" style={{ borderRadius: 14, padding: 20, marginBottom: 14, border: '1px solid rgba(0,212,160,0.22)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 16 }}>
|
||||||
|
<span style={{ width: 6, height: 6, background: 'var(--g-a)', borderRadius: 1 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)' }}>VYNDR INTELLIGENCE</span>
|
||||||
|
<span style={{ flex: 1, height: 1, background: 'rgba(0,212,160,0.14)' }} />
|
||||||
|
<span className="mono" style={{ fontSize: 10, color: 'var(--text-2)' }}>proprietary</span>
|
||||||
|
</div>
|
||||||
|
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
|
{p.intel.map((m, i) => (
|
||||||
|
<div key={i} style={{ background: '#080D0C', padding: '14px 15px', minHeight: 74, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 9.5, color: 'var(--text-2)', letterSpacing: '0.06em' }}>{m.label}</span>
|
||||||
|
{m.kind === 'grade' ? (
|
||||||
|
<GradeBadge grade={m.value} size="md" />
|
||||||
|
) : m.kind === 'form' ? (
|
||||||
|
<>
|
||||||
|
<div className="mono" style={{ fontSize: 22, fontWeight: 600, color: m.color }}>{m.value}</div>
|
||||||
|
<div style={{ height: 4, borderRadius: 2, background: 'rgba(255,255,255,0.08)', overflow: 'hidden' }}><div style={{ height: '100%', width: m.score, background: m.color, boxShadow: `0 0 8px ${m.color}` }} /></div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="mono" style={{ fontSize: 18, fontWeight: 600, color: m.color }}>{m.value}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* D. ACTIVE PROPS */}
|
||||||
|
{p.activeProps?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionLabel>ACTIVE PROPS · TONIGHT</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 9, marginBottom: 8 }}>
|
||||||
|
{p.activeProps.map((pr, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap', background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 11, padding: '13px 16px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, minWidth: 150 }}>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 14, color: '#fff' }}>{pr.stat}</span>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{pr.side} {pr.line}</span>
|
||||||
|
</div>
|
||||||
|
<GradeBadge grade={pr.grade} size="md" />
|
||||||
|
{pr.confidence && <span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>conf <span style={{ color: '#B8BCC8' }}>{pr.confidence}</span></span>}
|
||||||
|
<a href={`/scan?player=${encodeURIComponent(p.player)}&stat=${encodeURIComponent(pr.stat)}`} className="mono" style={{ marginLeft: 'auto', cursor: 'pointer', background: 'transparent', border: '1px solid var(--g-a)', borderRadius: 7, padding: '7px 13px', fontSize: 11, fontWeight: 600, letterSpacing: '0.04em', color: 'var(--g-a)', textDecoration: 'none' }}>GRADE PROP →</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* E/F. SEASON + LAST 10 */}
|
||||||
|
{(p.season?.length > 0 || p.last10?.length > 0) && (
|
||||||
|
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 24 }}>
|
||||||
|
{p.season?.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||||
|
<SectionLabel>SEASON STATS</SectionLabel>
|
||||||
|
<div className="mono" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{p.season.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderTop: i ? '1px solid #14141E' : 'none', fontSize: 12 }}>
|
||||||
|
<span style={{ color: 'var(--text-1)' }}>{r.k}</span>
|
||||||
|
<span style={{ color: '#fff', fontWeight: 600 }}>{r.v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.last10?.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||||
|
<SectionLabel>LAST 10</SectionLabel>
|
||||||
|
<div className="mono" style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{p.last10.map((g, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderTop: i ? '1px solid #14141E' : 'none', fontSize: 11 }}>
|
||||||
|
<span style={{ color: 'var(--text-1)', width: 48 }}>{g.d}</span>
|
||||||
|
<span style={{ color: 'var(--text-1)', width: 58 }}>{g.opp}</span>
|
||||||
|
<span style={{ color: '#C8CCD6', flex: 1, textAlign: 'right' }}>{g.stat}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* G/H. SPLITS + GRADE HISTORY */}
|
||||||
|
{(p.splits?.length > 0 || p.gradeHistory?.length > 0) && (
|
||||||
|
<div className="terminal-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginTop: 14 }}>
|
||||||
|
{p.splits?.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||||
|
<SectionLabel>SPLITS</SectionLabel>
|
||||||
|
<div className="mono" style={{ display: 'flex', flexDirection: 'column', fontSize: 12 }}>
|
||||||
|
{p.splits.map((r, i) => (
|
||||||
|
<div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 12, padding: '7px 0', borderTop: i ? '1px solid #14141E' : 'none' }}>
|
||||||
|
<span style={{ color: 'var(--text-1)' }}>{r.k}</span>
|
||||||
|
<span style={{ color: '#fff', fontWeight: 600 }}>{r.a}</span>
|
||||||
|
<span style={{ color: '#C8CCD6' }}>{r.b}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.gradeHistory?.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 18 }}>
|
||||||
|
<SectionLabel>GRADE HISTORY</SectionLabel>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{p.gradeHistory.map((g, i) => (
|
||||||
|
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 0', borderTop: i ? '1px solid #14141E' : 'none' }}>
|
||||||
|
<GradeBadge grade={g.grade} size="sm" />
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{g.prop}</span>
|
||||||
|
{g.hit && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 700, color: 'var(--hit)' }}>● HIT</span>}
|
||||||
|
{g.miss && <span className="mono" style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 700, color: 'var(--miss)' }}>● MISS</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state when there is no graded prop yet */}
|
||||||
|
{!p.found && (
|
||||||
|
<div style={{ marginTop: 24, padding: 20, border: '1px dashed var(--border-hi)', borderRadius: 12, textAlign: 'center' }}>
|
||||||
|
<p className="mono" style={{ color: 'var(--text-1)', fontSize: 13 }}>No graded props for {p.player} tonight.</p>
|
||||||
|
<p style={{ color: 'var(--text-2)', fontSize: 12, marginTop: 6 }}>The archetype + prop DNA above still apply. Check back when tonight's slate posts.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,212 @@
|
|||||||
import { redirect } from 'next/navigation';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /settings (Session 41 — P0 audit fix).
|
* /settings (Session 42 — Player Intelligence design).
|
||||||
*
|
*
|
||||||
* The audit found /settings 404'd. Account/preferences already live on
|
* Replaces the Session-41 redirect with the design's full settings surface.
|
||||||
* /profile (the canonical surface), so forward there rather than build a
|
* NOTE: /settings/security is a REAL MFA enrollment page — this page LINKS to
|
||||||
* second screen that would drift out of sync. Server-side redirect — no
|
* it, it does not replace it.
|
||||||
* flash of an empty page.
|
|
||||||
*/
|
*/
|
||||||
export default function SettingsPage() {
|
|
||||||
redirect('/profile');
|
const Section = ({ label, color = 'var(--text-1)', dot = 'var(--g-a)', children }: { label: string; color?: string; dot?: string; children: React.ReactNode }) => (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '18px 20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 16 }}>
|
||||||
|
<span style={{ width: 6, height: 6, background: dot, borderRadius: 1 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Row = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '9px 0', gap: 12, flexWrap: 'wrap' }}>{children}</div>
|
||||||
|
);
|
||||||
|
const Divider = () => <div style={{ height: 1, background: '#14141E', margin: '4px 0' }} />;
|
||||||
|
|
||||||
|
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} aria-pressed={on} style={{ cursor: 'pointer', appearance: 'none', position: 'relative', width: 40, height: 22, borderRadius: 11, border: '1px solid var(--border-hi)', background: on ? 'var(--g-a)' : '#15151f' }}>
|
||||||
|
<span style={{ position: 'absolute', top: 1, left: on ? 19 : 1, width: 18, height: 18, borderRadius: '50%', background: on ? '#06060B' : '#7a7a8e', transition: '.18s' }} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierLabel(tier: string) {
|
||||||
|
if (tier === 'desk') return { label: 'DESK', color: '#00ffb8' };
|
||||||
|
if (tier === 'analyst') return { label: 'ANALYST', color: 'var(--g-b)' };
|
||||||
|
return { label: 'FREE', color: 'var(--text-1)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, tier } = useAuth();
|
||||||
|
const [emailAlerts, setEmailAlerts] = useState(true);
|
||||||
|
const [pushAlerts, setPushAlerts] = useState(false);
|
||||||
|
const [deleteText, setDeleteText] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [delError, setDelError] = useState('');
|
||||||
|
|
||||||
|
const plan = tierLabel(tier || 'free');
|
||||||
|
const canDelete = deleteText === 'DELETE';
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!canDelete || deleting) return;
|
||||||
|
setDeleting(true);
|
||||||
|
setDelError('');
|
||||||
|
try {
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('sb-token') : null;
|
||||||
|
const res = await fetch('/api/user/profile', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
router.replace('/?deleted=1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No fake success — if the backend can't process it, say so.
|
||||||
|
setDelError('Account deletion could not be completed automatically. Email support@vyndr.app and we will remove your account within 48 hours.');
|
||||||
|
} catch {
|
||||||
|
setDelError('Network error. Try again, or email support@vyndr.app.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 640, margin: '0 auto', padding: '24px 16px 120px' }}>
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: 'var(--g-a)', letterSpacing: '0.12em', marginBottom: 10 }}>SETTINGS</div>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.01em' }}>Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
{/* ACCOUNT */}
|
||||||
|
<Section label="ACCOUNT">
|
||||||
|
<Row>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>Email</span>
|
||||||
|
<span className="mono" style={{ fontSize: 13, color: 'var(--text-0)' }}>{user?.email || '—'}</span>
|
||||||
|
</Row>
|
||||||
|
<Divider />
|
||||||
|
<Row>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-1)' }}>Plan</span>
|
||||||
|
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', fontWeight: 700, fontSize: 11, letterSpacing: '0.06em', color: plan.color, background: 'color-mix(in srgb, currentColor 12%, transparent)', border: '1px solid color-mix(in srgb, currentColor 40%, transparent)', padding: '3px 10px', borderRadius: 5 }}>{plan.label}</span>
|
||||||
|
</Row>
|
||||||
|
<Divider />
|
||||||
|
<Row>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Security & two-factor</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Authenticator-app MFA</div>
|
||||||
|
</div>
|
||||||
|
<a href="/settings/security" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)', textDecoration: 'none' }}>MANAGE →</a>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* SUBSCRIPTION */}
|
||||||
|
<Section label="SUBSCRIPTION">
|
||||||
|
<Row>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#fff' }}>VYNDR {plan.label === 'FREE' ? 'Free' : plan.label === 'DESK' ? 'Desk' : 'Analyst'}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>{plan.label === 'FREE' ? '5 scans / month' : 'Active subscription'}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 9 }}>
|
||||||
|
{plan.label !== 'FREE' && (
|
||||||
|
<button onClick={() => router.push('/profile')} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '9px 15px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)' }}>MANAGE</button>
|
||||||
|
)}
|
||||||
|
<a href="/pricing" className="mono" style={{ cursor: 'pointer', background: 'var(--g-a)', border: '1px solid var(--g-a)', borderRadius: 8, padding: '9px 15px', fontSize: 11, fontWeight: 700, color: '#06060B', textDecoration: 'none' }}>{plan.label === 'FREE' ? 'UPGRADE' : 'CHANGE PLAN'}</a>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* NOTIFICATIONS */}
|
||||||
|
<Section label="NOTIFICATIONS">
|
||||||
|
<Row>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Email alerts</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Grade locks and line moves</div>
|
||||||
|
</div>
|
||||||
|
<Toggle on={emailAlerts} onClick={() => setEmailAlerts((v) => !v)} />
|
||||||
|
</Row>
|
||||||
|
<Divider />
|
||||||
|
<Row>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Push alerts</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Injury news for tracked players</div>
|
||||||
|
</div>
|
||||||
|
<Toggle on={pushAlerts} onClick={() => setPushAlerts((v) => !v)} />
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* DISPLAY */}
|
||||||
|
<Section label="DISPLAY PREFERENCES">
|
||||||
|
<Row>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text-0)' }}>Display, odds format & accessibility</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-1)', marginTop: 2 }}>Region, odds format, text size, motion, contrast, colorblind-safe</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => window.__prefs?.()} className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)' }}>OPEN</button>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* RESPONSIBLE PLAY */}
|
||||||
|
<Section label="RESPONSIBLE PLAY" color="var(--text-1)" dot="var(--amber)">
|
||||||
|
<Row>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>Set a weekly time limit</span>
|
||||||
|
<a href="/responsible-gambling" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid var(--border-hi)', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--text-1)', textDecoration: 'none' }}>CONFIGURE</a>
|
||||||
|
</Row>
|
||||||
|
<Divider />
|
||||||
|
<Row>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text-0)' }}>Self-exclusion</span>
|
||||||
|
<a href="/responsible-gambling" className="mono" style={{ cursor: 'pointer', background: 'transparent', border: '1px solid #FFB34740', borderRadius: 8, padding: '8px 14px', fontSize: 11, fontWeight: 600, color: 'var(--amber)', textDecoration: 'none' }}>TAKE A BREAK</a>
|
||||||
|
</Row>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* DANGER ZONE */}
|
||||||
|
<div style={{ background: '#0E0808', border: '1px solid #3A1A1A', borderRadius: 12, padding: '18px 20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 14 }}>
|
||||||
|
<span style={{ width: 6, height: 6, background: 'var(--miss)', borderRadius: 1 }} />
|
||||||
|
<span className="mono" style={{ fontSize: 11, fontWeight: 600, letterSpacing: '0.1em', color: '#FF7A6B' }}>DANGER ZONE</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '0 0 14px', fontSize: 13, lineHeight: 1.55, color: '#9A8585' }}>
|
||||||
|
Deleting your account is permanent. All grade history and tracked players will be lost. Type{' '}
|
||||||
|
<strong className="mono" style={{ color: '#FF7A6B' }}>DELETE</strong> to confirm.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
value={deleteText}
|
||||||
|
onChange={(e) => setDeleteText(e.target.value)}
|
||||||
|
placeholder="Type DELETE"
|
||||||
|
aria-label="Type DELETE to confirm account deletion"
|
||||||
|
className="mono"
|
||||||
|
style={{ flex: 1, minWidth: 160, appearance: 'none', background: '#0A0606', border: '1px solid #3A1A1A', borderRadius: 8, padding: '10px 13px', fontSize: 13, color: '#fff', letterSpacing: '0.08em', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={!canDelete || deleting}
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
cursor: canDelete && !deleting ? 'pointer' : 'not-allowed',
|
||||||
|
appearance: 'none',
|
||||||
|
background: canDelete ? 'var(--miss)' : '#1A0E0E',
|
||||||
|
border: `1px solid ${canDelete ? 'var(--miss)' : '#3A1A1A'}`,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '10px 18px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: canDelete ? '#0A0606' : '#6A4A4A',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
transition: '.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting ? 'WORKING…' : 'DELETE ACCOUNT'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{delError && <p className="mono" style={{ marginTop: 12, fontSize: 12, color: '#FFB0A4', lineHeight: 1.5 }}>{delError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ const PRIMARY = [
|
|||||||
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
{ id: 'ledger', label: 'Ledger', href: '/ledger' },
|
||||||
];
|
];
|
||||||
const MORE = [
|
const MORE = [
|
||||||
|
{ label: 'Explore', href: '/explore' },
|
||||||
{ label: 'Compare', href: '/compare' },
|
{ label: 'Compare', href: '/compare' },
|
||||||
{ label: 'Tracker', href: '/tracker' },
|
{ label: 'Tracker', href: '/tracker' },
|
||||||
{ label: 'The Report', href: '/blog' },
|
{ label: 'The Report', href: '/blog' },
|
||||||
{ label: 'Invite', href: '/invite' },
|
{ label: 'Invite', href: '/invite' },
|
||||||
{ label: 'Pricing', href: '/pricing' },
|
{ label: 'Pricing', href: '/pricing' },
|
||||||
{ label: 'Settings', href: '/settings/security' },
|
{ label: 'Settings', href: '/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TICKER_ITEMS = [
|
const TICKER_ITEMS = [
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { badgeStyle, glyphSvg } from '@/lib/archetypes';
|
||||||
|
|
||||||
|
interface ArchetypeBadgeProps {
|
||||||
|
archetype: string; // archetype name, e.g. "POWER PULL" (case-insensitive)
|
||||||
|
sport?: string; // nba/mlb/wnba/soccer — informational; styling is per-archetype
|
||||||
|
variant?: 'full' | 'ghost' | 'tint';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
showDesc?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArchetypeBadge (Session 42) — ported pixel-for-pixel from the design's
|
||||||
|
* ArchetypeBadge.dc.html. JetBrains Mono label + a per-archetype glyph.
|
||||||
|
* full = solid fill (PRIMARY)
|
||||||
|
* ghost = transparent + colored border (SECONDARY)
|
||||||
|
* tint = subtle tinted bg (default)
|
||||||
|
* Data never glitches — this is a chrome label, no animation.
|
||||||
|
*/
|
||||||
|
export default function ArchetypeBadge({
|
||||||
|
archetype,
|
||||||
|
variant = 'tint',
|
||||||
|
size = 'sm',
|
||||||
|
showDesc = false,
|
||||||
|
}: ArchetypeBadgeProps) {
|
||||||
|
const s = badgeStyle(archetype, variant, size);
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 5,
|
||||||
|
fontWeight: 700,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.07em',
|
||||||
|
lineHeight: 1,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
fontSize: s.fontSize,
|
||||||
|
padding: s.padding,
|
||||||
|
borderRadius: s.radius,
|
||||||
|
color: s.textColor,
|
||||||
|
background: s.bg,
|
||||||
|
border: `1px solid ${s.borderColor}`,
|
||||||
|
textShadow: s.textShadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
|
||||||
|
/>
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
{showDesc && s.desc && (
|
||||||
|
<span style={{ fontFamily: 'var(--sans)', fontSize: s.descSize, color: '#7A7E8C', whiteSpace: 'nowrap' }}>
|
||||||
|
{s.desc}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { archetypeColor } from '@/lib/archetypes';
|
||||||
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
|
|
||||||
|
export interface BlendSegment {
|
||||||
|
archetype: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchetypeBlendProps {
|
||||||
|
blend: BlendSegment[];
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
showLegend?: boolean;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArchetypeBlend (Session 42) — the production-DNA bar. Ported from the
|
||||||
|
* design's ArchetypeBlend.dc.html. Each segment's width is that archetype's
|
||||||
|
* share of the player's statistical value; the widest is PRIMARY.
|
||||||
|
*/
|
||||||
|
export default function ArchetypeBlend({
|
||||||
|
blend,
|
||||||
|
size = 'md',
|
||||||
|
showLegend = true,
|
||||||
|
caption = '',
|
||||||
|
}: ArchetypeBlendProps) {
|
||||||
|
const sm = size === 'sm';
|
||||||
|
const raw = (Array.isArray(blend) ? blend : []).slice(0, 4);
|
||||||
|
const total = raw.reduce((s, b) => s + (Number(b.weight) || 0), 0) || 1;
|
||||||
|
const pct = (w: number) => Math.round(((Number(w) || 0) / total) * 100);
|
||||||
|
|
||||||
|
const barH = sm ? 6 : 10;
|
||||||
|
const gap = sm ? 7 : 13;
|
||||||
|
const pctSize = sm ? 10 : 13;
|
||||||
|
const capSize = sm ? 11 : 12.5;
|
||||||
|
const roleW = sm ? 54 : 66;
|
||||||
|
const ROLECOL = ['#C6CBD6', '#6B6F7E', '#6B6F7E', '#5A5E6B'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap, width: '100%' }}>
|
||||||
|
<div style={{ position: 'relative', display: 'flex', height: barH, borderRadius: 99, overflow: 'hidden', gap: 1.5, background: '#07070D', border: '1px solid #15151F' }}>
|
||||||
|
{raw.map((b, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
title={`${b.archetype} — ${pct(b.weight)}% of production profile`}
|
||||||
|
style={{
|
||||||
|
width: `${(Math.max(0, Number(b.weight) || 0) / total) * 100}%`,
|
||||||
|
background: archetypeColor(b.archetype),
|
||||||
|
boxShadow: `inset 0 0 0 100px ${i === 0 ? 'transparent' : 'rgba(0,0,0,0.18)'}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLegend && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '9px 16px', alignItems: 'center' }}>
|
||||||
|
{raw.map((b, i) => (
|
||||||
|
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="mono" style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.08em', color: ROLECOL[i] || '#5A5E6B', width: roleW, textAlign: 'right' }}>
|
||||||
|
{i === 0 ? 'PRIMARY' : i === raw.length - 1 && raw.length > 2 ? 'TERTIARY' : 'SUPPORTING'}
|
||||||
|
</span>
|
||||||
|
<ArchetypeBadge archetype={b.archetype} size={sm ? 'sm' : 'md'} variant={i === 0 ? 'full' : 'ghost'} />
|
||||||
|
<span className="mono" style={{ fontSize: pctSize, fontWeight: 700, color: i === 0 ? '#FFFFFF' : '#9499A8' }}>
|
||||||
|
{pct(b.weight)}%
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{caption && (
|
||||||
|
<div style={{ fontFamily: 'var(--sans)', fontSize: capSize, lineHeight: 1.5, color: '#7E8390' }}>{caption}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { bookInfo } from '@/lib/books';
|
||||||
|
|
||||||
|
interface BookChipProps {
|
||||||
|
book: string; // DK / FD / MGM / CZR / ESPN / BR / PB ... (case-insensitive)
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
showName?: boolean;
|
||||||
|
nameColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BookChip (Session 42) — a sportsbook tile with the book's brand color, ported
|
||||||
|
* from the design's BookChip.dc.html. JetBrains Mono mono-code in a tinted tile;
|
||||||
|
* optional full name. Data chrome — never glitches.
|
||||||
|
*/
|
||||||
|
export default function BookChip({ book, size = 'sm', showName = false, nameColor = '#E6E8EE' }: BookChipProps) {
|
||||||
|
const b = bookInfo(book);
|
||||||
|
const sm = size === 'sm';
|
||||||
|
const tile = sm ? 26 : 34;
|
||||||
|
const monoSize = sm ? (b.mono.length > 2 ? 8 : 10) : (b.mono.length > 2 ? 10 : 13);
|
||||||
|
return (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: sm ? 8 : 10, verticalAlign: 'middle' }}>
|
||||||
|
<span
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 'none',
|
||||||
|
fontWeight: 800,
|
||||||
|
lineHeight: 1,
|
||||||
|
width: tile,
|
||||||
|
height: tile,
|
||||||
|
borderRadius: sm ? 6 : 8,
|
||||||
|
fontSize: monoSize,
|
||||||
|
color: b.fg,
|
||||||
|
background: b.bg,
|
||||||
|
border: `1px solid ${b.bd}`,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b.mono}
|
||||||
|
</span>
|
||||||
|
{showName && (
|
||||||
|
<span style={{ fontFamily: 'var(--sans)', fontSize: sm ? 12 : 14, fontWeight: 600, color: nameColor, whiteSpace: 'nowrap' }}>
|
||||||
|
{b.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
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 GradeBadge from '@/components/vyndr/GradeBadge';
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
|
import StatStrip, { type StatCell, type StripProp, type StripArchetype } from '@/components/vyndr/StatStrip';
|
||||||
|
import { playerHref } from '@/lib/playerHref';
|
||||||
|
|
||||||
export interface GameLine {
|
export interface GameLine {
|
||||||
book: string;
|
book: string;
|
||||||
@@ -23,6 +26,19 @@ export interface GameProp {
|
|||||||
side: string;
|
side: string;
|
||||||
delta?: string;
|
delta?: string;
|
||||||
}
|
}
|
||||||
|
/** Session 42 — design's enhanced card: one strip per player (name once). */
|
||||||
|
export interface PlayerStrip {
|
||||||
|
player: string;
|
||||||
|
team: string;
|
||||||
|
archetype?: StripArchetype;
|
||||||
|
stats: StatCell[];
|
||||||
|
props: StripProp[];
|
||||||
|
}
|
||||||
|
export interface StartingPitcher {
|
||||||
|
name: string;
|
||||||
|
era: string;
|
||||||
|
archetype?: string;
|
||||||
|
}
|
||||||
export interface GameCardData {
|
export interface GameCardData {
|
||||||
id: string;
|
id: string;
|
||||||
sport: string;
|
sport: string;
|
||||||
@@ -37,6 +53,9 @@ export interface GameCardData {
|
|||||||
lines: GameLine[];
|
lines: GameLine[];
|
||||||
props?: GameProp[];
|
props?: GameProp[];
|
||||||
streaks?: Array<{ player: string; text: string }>;
|
streaks?: Array<{ player: string; text: string }>;
|
||||||
|
// Session 42 — Player Intelligence enhancements (optional, self-hiding).
|
||||||
|
pitchers?: { away: StartingPitcher; home: StartingPitcher };
|
||||||
|
playerStrips?: PlayerStrip[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GameCardProps {
|
interface GameCardProps {
|
||||||
@@ -130,6 +149,22 @@ export default function GameCard({ game: g, onAddParlay, onOpen }: GameCardProps
|
|||||||
{g.venue && (<><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</>)}
|
{g.venue && (<><span style={{ color: 'var(--text-2)' }}> · </span>{g.venue}</>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* MLB STARTING PITCHERS (Session 42) */}
|
||||||
|
{g.pitchers && (
|
||||||
|
<div className="mono" style={{ padding: '0 16px 11px', fontSize: 11, color: 'var(--text-1)', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ color: 'var(--text-2)', letterSpacing: '0.04em' }}>STARTING</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: '#C8CCD6' }}>
|
||||||
|
{g.pitchers.away.name} <span style={{ color: 'var(--text-1)' }}>{g.pitchers.away.era} ERA</span>
|
||||||
|
{g.pitchers.away.archetype && <ArchetypeBadge archetype={g.pitchers.away.archetype} size="sm" />}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>vs</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: '#C8CCD6' }}>
|
||||||
|
{g.pitchers.home.name} <span style={{ color: 'var(--text-1)' }}>{g.pitchers.home.era} ERA</span>
|
||||||
|
{g.pitchers.home.archetype && <ArchetypeBadge archetype={g.pitchers.home.archetype} size="sm" />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||||
|
|
||||||
{/* GAME LINES */}
|
{/* GAME LINES */}
|
||||||
@@ -155,10 +190,28 @@ export default function GameCard({ game: g, onAddParlay, onOpen }: GameCardProps
|
|||||||
|
|
||||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||||
|
|
||||||
{/* PROPS */}
|
{/* PROPS — Session 42: prefer the player-grouped StatStrip (name once,
|
||||||
|
archetype + horizontal stats + all graded props on one line); fall
|
||||||
|
back to the legacy per-prop rows for callers that don't supply strips. */}
|
||||||
<div style={{ padding: '13px 16px' }}>
|
<div style={{ padding: '13px 16px' }}>
|
||||||
<SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
|
<SectionHead style={{ marginBottom: 11 }}>GRADED PROPS</SectionHead>
|
||||||
{g.props && g.props.length > 0 ? (
|
{g.playerStrips && g.playerStrips.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{g.playerStrips.map((ps, i) => (
|
||||||
|
<StatStrip
|
||||||
|
key={i}
|
||||||
|
player={ps.player}
|
||||||
|
team={ps.team}
|
||||||
|
sport={g.sport}
|
||||||
|
archetype={ps.archetype}
|
||||||
|
stats={ps.stats}
|
||||||
|
props={ps.props}
|
||||||
|
variant="compact"
|
||||||
|
onPlayerClick={() => { window.location.href = playerHref(ps.player, g.sport); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : g.props && g.props.length > 0 ? (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{g.props.map((p, i) => (
|
{g.props.map((p, i) => (
|
||||||
<PropRow key={i} prop={p} onAddParlay={onAddParlay} />
|
<PropRow key={i} prop={p} onAddParlay={onAddParlay} />
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { useEffect, useState } from 'react';
|
|||||||
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 VBtn from '@/components/vyndr/VBtn';
|
import VBtn from '@/components/vyndr/VBtn';
|
||||||
|
import ArchetypeBlend from '@/components/vyndr/ArchetypeBlend';
|
||||||
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
import { gradeColor, gradeHex } from '@/lib/vyndrTokens';
|
||||||
|
import { playerHref } from '@/lib/playerHref';
|
||||||
|
|
||||||
export interface GradeResultData {
|
export interface GradeResultData {
|
||||||
player: string;
|
player: string;
|
||||||
@@ -22,6 +25,11 @@ export interface GradeResultData {
|
|||||||
killConditions?: string[];
|
killConditions?: string[];
|
||||||
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
|
books: Array<{ name: string; line: number; odds: string; best?: boolean }>;
|
||||||
altLadder?: Array<{ line: number; grade: string }>;
|
altLadder?: Array<{ line: number; grade: string }>;
|
||||||
|
// Session 42 — Player Intelligence additions (all optional; self-hide).
|
||||||
|
archetypeBlend?: Array<{ archetype: string; weight: number }>;
|
||||||
|
propDNA?: { reliable: string[]; volatile: string[] };
|
||||||
|
statContext?: { season?: string; last10?: string; vsOpp?: string };
|
||||||
|
vyndrIntel?: { form?: number | string; usage?: string; matchup?: string; rest?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GradeResultCardProps {
|
interface GradeResultCardProps {
|
||||||
@@ -80,10 +88,10 @@ export default function GradeResultCard({
|
|||||||
>
|
>
|
||||||
{sweep && <div className="crt-sweep-local" />}
|
{sweep && <div className="crt-sweep-local" />}
|
||||||
|
|
||||||
{/* 1. HEADER */}
|
{/* 1. HEADER — player name links to the full profile (Session 42) */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px', background: 'var(--bg-2)', borderBottom: '1px solid var(--border)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1 }}>{d.player}</div>
|
<a href={playerHref(d.player, d.sport)} style={{ fontSize: 22, fontWeight: 800, letterSpacing: '-0.01em', lineHeight: 1.1, color: 'inherit', textDecoration: 'none' }}>{d.player}</a>
|
||||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)', marginTop: 3 }}>
|
||||||
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
|
<span style={{ color: sideColor, fontWeight: 700 }}>{d.side.toUpperCase()} {d.line}</span>
|
||||||
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
<span style={{ color: 'var(--text-2)', margin: '0 7px' }}>·</span>{d.stat}
|
||||||
@@ -95,6 +103,24 @@ export default function GradeResultCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 1b. ARCHETYPE STRIP (Session 42) — between header and grade hero */}
|
||||||
|
{Array.isArray(d.archetypeBlend) && d.archetypeBlend.length > 0 && (
|
||||||
|
<div style={{ padding: '13px 20px', background: '#0C0E0D', borderBottom: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<ArchetypeBlend blend={d.archetypeBlend} size="sm" showLegend caption="Why this grade: the prop sits in this player's PRIMARY lane, so the model weights it heavily." />
|
||||||
|
{d.propDNA && (
|
||||||
|
<div className="mono" style={{ fontSize: 11, color: '#7E8A86' }}>
|
||||||
|
<span style={{ color: 'var(--text-2)' }}>PROP DNA</span>
|
||||||
|
{(d.propDNA.reliable || []).map((s) => (
|
||||||
|
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--g-a)' }}>● reliable</span></span>
|
||||||
|
))}
|
||||||
|
{(d.propDNA.volatile || []).map((s) => (
|
||||||
|
<span key={s}> · {s.replace(/_/g, ' ')} <span style={{ color: 'var(--amber)' }}>● volatile</span></span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 2. GRADE HERO — intel surface */}
|
{/* 2. GRADE HERO — intel surface */}
|
||||||
<div className="intel-surface" style={{ padding: '26px 20px 22px', textAlign: 'center' }}>
|
<div className="intel-surface" style={{ padding: '26px 20px 22px', textAlign: 'center' }}>
|
||||||
<div className="label" style={{ position: 'relative', zIndex: 2, color: 'rgba(232,255,244,.5)', marginBottom: 2 }}>VYNDR GRADE</div>
|
<div className="label" style={{ position: 'relative', zIndex: 2, color: 'rgba(232,255,244,.5)', marginBottom: 2 }}>VYNDR GRADE</div>
|
||||||
@@ -162,6 +188,38 @@ export default function GradeResultCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 5b. STAT CONTEXT (Session 42) */}
|
||||||
|
{d.statContext && (d.statContext.season || d.statContext.last10 || d.statContext.vsOpp) && (
|
||||||
|
<div style={{ padding: '16px 20px', borderTop: '1px solid var(--border)' }}>
|
||||||
|
<SectionHead style={{ marginBottom: 12 }}>STAT CONTEXT</SectionHead>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 1, background: 'var(--border)', border: '1px solid var(--border)', borderRadius: 9, overflow: 'hidden' }}>
|
||||||
|
{[
|
||||||
|
{ l: 'SEASON', v: d.statContext.season, col: 'var(--text-0)' },
|
||||||
|
{ l: 'LAST 10', v: d.statContext.last10, col: 'var(--g-a)' },
|
||||||
|
{ l: 'vs OPP', v: d.statContext.vsOpp, col: 'var(--g-a)' },
|
||||||
|
].map((x, i) => (
|
||||||
|
<div key={i} style={{ background: 'var(--bg-1)', padding: '11px 12px' }}>
|
||||||
|
<div className="mono" style={{ fontSize: 9, color: 'var(--text-2)', letterSpacing: '0.06em', marginBottom: 5 }}>{x.l}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 16, fontWeight: 600, color: x.v ? x.col : 'var(--text-2)' }}>{x.v || '—'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 5c. VYNDR INTELLIGENCE (Session 42) */}
|
||||||
|
{d.vyndrIntel && (
|
||||||
|
<div className="intel-surface" style={{ margin: '0 20px 16px', padding: '15px 16px', borderRadius: 12, border: '1px solid rgba(0,212,160,0.24)' }}>
|
||||||
|
<div className="mono" style={{ fontSize: 10, fontWeight: 600, letterSpacing: '0.1em', color: 'var(--g-a)', marginBottom: 12 }}>VYNDR INTELLIGENCE</div>
|
||||||
|
<div className="mono" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center', fontSize: 12 }}>
|
||||||
|
{d.vyndrIntel.form != null && (<><span style={{ color: 'var(--text-2)' }}>Form</span><span style={{ color: 'var(--g-ap)', fontWeight: 700 }}>{d.vyndrIntel.form}</span><span style={{ color: '#2A3531' }}>·</span></>)}
|
||||||
|
{d.vyndrIntel.usage && (<><span style={{ color: 'var(--text-2)' }}>Usage</span><span style={{ color: 'var(--text-0)', fontWeight: 600 }}>{d.vyndrIntel.usage}</span><span style={{ color: '#2A3531' }}>·</span></>)}
|
||||||
|
{d.vyndrIntel.matchup && (<><span style={{ color: 'var(--text-2)' }}>Matchup</span><GradeBadge grade={d.vyndrIntel.matchup} size="sm" /><span style={{ color: '#2A3531' }}>·</span></>)}
|
||||||
|
{d.vyndrIntel.rest && (<><span style={{ color: 'var(--text-2)' }}>Rest</span><span style={{ color: 'var(--g-a)', fontWeight: 600 }}>{d.vyndrIntel.rest}</span></>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 6. KILL CONDITIONS */}
|
{/* 6. KILL CONDITIONS */}
|
||||||
{hasKill && (
|
{hasKill && (
|
||||||
<div style={{ margin: '0 20px 16px', border: '1px solid rgba(255,179,71,.4)', borderRadius: 8, background: 'rgba(255,179,71,.06)', padding: '13px 15px' }}>
|
<div style={{ margin: '0 20px 16px', border: '1px solid rgba(255,179,71,.4)', borderRadius: 8, background: 'rgba(255,179,71,.06)', padding: '13px 15px' }}>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import ArchetypeBadge from '@/components/vyndr/ArchetypeBadge';
|
||||||
|
import GradeBadge from '@/components/vyndr/GradeBadge';
|
||||||
|
|
||||||
|
export interface StatCell {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
export interface StripProp {
|
||||||
|
stat: string;
|
||||||
|
line: number | string;
|
||||||
|
side: string; // O / U / Over / Under
|
||||||
|
grade: string;
|
||||||
|
}
|
||||||
|
export interface StripArchetype {
|
||||||
|
primary: string;
|
||||||
|
secondary?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatStripProps {
|
||||||
|
player: string;
|
||||||
|
team: string;
|
||||||
|
sport?: string;
|
||||||
|
archetype?: StripArchetype;
|
||||||
|
stats: StatCell[];
|
||||||
|
last10?: StatCell[] | string;
|
||||||
|
props?: StripProp[];
|
||||||
|
meta?: string; // expanded: "ATL · 3B · #27"
|
||||||
|
variant?: 'compact' | 'expanded';
|
||||||
|
onPlayerClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Sep = ({ ch = '|' }: { ch?: string }) => (
|
||||||
|
<span style={{ color: '#3A3A48', margin: '0 8px' }}>{ch}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StatStrip (Session 42) — the horizontal player line. Ported from the design's
|
||||||
|
* Stat Strip section. The player name appears ONCE; stats flow horizontally as
|
||||||
|
* a JetBrains Mono run (never stacked with the name repeated per stat).
|
||||||
|
* compact = game cards / inline
|
||||||
|
* expanded = profile hero / grade result
|
||||||
|
*/
|
||||||
|
export default function StatStrip({
|
||||||
|
player,
|
||||||
|
team,
|
||||||
|
archetype,
|
||||||
|
stats,
|
||||||
|
last10,
|
||||||
|
props,
|
||||||
|
meta,
|
||||||
|
variant = 'compact',
|
||||||
|
onPlayerClick,
|
||||||
|
}: StatStripProps) {
|
||||||
|
const last10Str = typeof last10 === 'string'
|
||||||
|
? last10
|
||||||
|
: Array.isArray(last10)
|
||||||
|
? last10.map((c) => `${c.value} ${c.label}`).join(' · ')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const nameStyle: React.CSSProperties = onPlayerClick
|
||||||
|
? { cursor: 'pointer', textDecoration: 'none', color: 'inherit' }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const PlayerName = ({ children, ...rest }: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) =>
|
||||||
|
onPlayerClick ? (
|
||||||
|
<span role="link" tabIndex={0} onClick={onPlayerClick} onKeyDown={(e) => { if (e.key === 'Enter') onPlayerClick(); }} style={{ ...nameStyle, ...(rest.style || {}) }}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span {...rest}>{children}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variant === 'expanded') {
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: 20 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<PlayerName style={{ fontWeight: 800, fontSize: 22, letterSpacing: '-0.01em', textTransform: 'uppercase', ...nameStyle }}>
|
||||||
|
{player}
|
||||||
|
</PlayerName>
|
||||||
|
<span className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>{meta || team}</span>
|
||||||
|
</div>
|
||||||
|
{archetype && (
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
<ArchetypeBadge archetype={archetype.primary} size="md" showDesc />
|
||||||
|
{archetype.secondary && <ArchetypeBadge archetype={archetype.secondary} size="md" variant="ghost" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ height: 1, background: 'var(--border)', margin: '16px 0' }} />
|
||||||
|
<div className="mono" style={{ display: 'flex', gap: 26, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<span style={{ fontSize: 18, color: '#fff', fontWeight: 600 }}>{s.value}</span>{' '}
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-1)' }}>{s.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{last10Str && (
|
||||||
|
<div className="mono" style={{ fontSize: 12, color: 'var(--text-1)' }}>
|
||||||
|
<span style={{ color: 'var(--g-a)', letterSpacing: '0.04em' }}>LAST 10</span>
|
||||||
|
<Sep ch="·" />
|
||||||
|
{last10Str}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--bg-1)', border: '1px solid var(--border)', borderRadius: 12, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 9 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
|
||||||
|
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
|
||||||
|
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
|
||||||
|
{archetype?.secondary && (
|
||||||
|
<>
|
||||||
|
<span className="mono" style={{ color: '#3A3A48', fontSize: 11 }}>/</span>
|
||||||
|
<ArchetypeBadge archetype={archetype.secondary} size="sm" variant="ghost" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mono game-lines-grid" style={{ fontSize: 12, color: 'var(--text-0)', letterSpacing: '0.02em' }}>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <Sep />}
|
||||||
|
{s.value} {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{props && props.length > 0 && (
|
||||||
|
<div className="game-lines-grid" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', overflowX: 'auto' }}>
|
||||||
|
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)', letterSpacing: '0.06em' }}>PROPS</span>
|
||||||
|
{props.map((p, i) => (
|
||||||
|
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{i > 0 && <span style={{ color: '#3A3A48' }}>·</span>}
|
||||||
|
<span className="mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#B8BCC8' }}>
|
||||||
|
{p.stat} {p.side}{p.line} <GradeBadge grade={p.grade} size="sm" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,9 +12,17 @@ export { default as GradeResultCard } from './GradeResultCard';
|
|||||||
export type { GradeResultData } from './GradeResultCard';
|
export type { GradeResultData } from './GradeResultCard';
|
||||||
export { default as ProcessingGrade } from './ProcessingGrade';
|
export { default as ProcessingGrade } from './ProcessingGrade';
|
||||||
export { default as GameCard } from './GameCard';
|
export { default as GameCard } from './GameCard';
|
||||||
export type { GameCardData, GameLine, GameProp } from './GameCard';
|
export type { GameCardData, GameLine, GameProp, PlayerStrip, StartingPitcher } from './GameCard';
|
||||||
export { default as ClaimMeter } from './ClaimMeter';
|
export { default as ClaimMeter } from './ClaimMeter';
|
||||||
|
|
||||||
|
/* Player Intelligence (Session 42) */
|
||||||
|
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||||
|
export { default as ArchetypeBlend } from './ArchetypeBlend';
|
||||||
|
export type { BlendSegment } from './ArchetypeBlend';
|
||||||
|
export { default as StatStrip } from './StatStrip';
|
||||||
|
export type { StatCell, StripProp, StripArchetype } from './StatStrip';
|
||||||
|
export { default as BookChip } from './BookChip';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
GRADE_COLORS,
|
GRADE_COLORS,
|
||||||
GRADE_HEX,
|
GRADE_HEX,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/* ============================================================
|
||||||
|
VYNDR 2.0 — archetype visual map (Session 42, §Player Intelligence).
|
||||||
|
Ported verbatim from the design's ArchetypeBadge.dc.html MAP +
|
||||||
|
ArchetypeBlend.dc.html. Plain CommonJS so .tsx imports it AND the
|
||||||
|
Jest suite can require it directly (same pattern as vyndrTokens.js).
|
||||||
|
Colors/glyphs MUST match src/services/archetypeService.js ARCHETYPES.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* Glyph SVG inner-markup keyed by name (drawn in a 0 0 16 16 viewBox). */
|
||||||
|
const GLYPHS = {
|
||||||
|
triangle: '<path d="M8 2 L14 14 L2 14 Z" fill="currentColor"/>',
|
||||||
|
node: '<circle cx="8" cy="8" r="2.3" fill="currentColor"/><path d="M8 5.7V1.5M5.4 9.3 2.4 13M10.6 9.3 13.6 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
|
||||||
|
shield: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" fill="currentColor"/>',
|
||||||
|
shieldCheck: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M5.6 8 L7.4 9.9 L10.4 6.2" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||||
|
target: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8" cy="8" r="2.2" fill="currentColor"/>',
|
||||||
|
bolt: '<path d="M9 1.6 L4 9 H7.6 L7 14.4 L12 6.6 H8.4 Z" fill="currentColor"/>',
|
||||||
|
twin: '<circle cx="6" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/><circle cx="10" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/>',
|
||||||
|
chain: '<rect x="2.4" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/><rect x="6.6" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/>',
|
||||||
|
batball: '<path d="M3 13.2 L11 5.2" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><circle cx="12.4" cy="3.6" r="1.9" fill="currentColor"/>',
|
||||||
|
crosshair: '<circle cx="8" cy="8" r="5.6" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 1.4V4.4M8 11.6V14.6M1.4 8H4.4M11.6 8H14.6" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1.5" fill="currentColor"/>',
|
||||||
|
diamond: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 5.4 L10.6 8 L8 10.6 L5.4 8 Z" fill="currentColor"/>',
|
||||||
|
diamondLine: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.7" fill="none"/>',
|
||||||
|
star: '<path d="M8 1.4 L9.7 6 L14.6 6.3 L10.7 9.3 L12.1 14 L8 11.2 L3.9 14 L5.3 9.3 L1.4 6.3 L6.3 6 Z" fill="currentColor"/>',
|
||||||
|
clock: '<circle cx="8" cy="9.2" r="5" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 9.2 V5.6 M6.2 2.2 H9.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
|
||||||
|
chevrons: '<path d="M2.6 4 L6.6 8 L2.6 12 M7.8 4 L11.8 8 L7.8 12" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||||
|
slash: '<path d="M3 13 L13 3 M13 3 H8.6 M13 3 V7.4" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||||
|
arc: '<path d="M2 11.5 Q8 1.5 14 11.5" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="13.4" cy="11.2" r="1.6" fill="currentColor"/>',
|
||||||
|
plus: '<path d="M8 2.4 V13.6 M2.4 8 H13.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="8" cy="8" r="1.9" fill="currentColor"/>',
|
||||||
|
lock: '<rect x="3.6" y="7" width="8.8" height="6.6" rx="1.4" fill="currentColor"/><path d="M5.4 7 V5 a2.6 2.6 0 0 1 5.2 0 V7" stroke="currentColor" stroke-width="1.5" fill="none"/>',
|
||||||
|
swap: '<path d="M3 5.6 H11.4 M9.4 3.6 L11.6 5.6 L9.4 7.6 M13 10.4 H4.6 M6.6 8.4 L4.4 10.4 L6.6 12.4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||||
|
half: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 2.2 a5.8 5.8 0 0 1 0 11.6 Z" fill="currentColor"/>',
|
||||||
|
uparrow: '<path d="M8 13.4 V3.6 M4.4 7.2 L8 3.6 L11.6 7.2" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||||
|
postup: '<rect x="6.4" y="2.6" width="3.2" height="11" rx="1.6" fill="currentColor"/><circle cx="12" cy="5.2" r="1.7" fill="currentColor"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* name → { color, glyph, desc } — the design's MAP. */
|
||||||
|
const ARCHETYPE_MAP = {
|
||||||
|
// NBA
|
||||||
|
'VOLUME SCORER': { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle' },
|
||||||
|
'FLOOR GENERAL': { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node' },
|
||||||
|
'TWO-WAY ANCHOR': { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield' },
|
||||||
|
'STRETCH BIG': { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target' },
|
||||||
|
'USAGE SPONGE': { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow' },
|
||||||
|
'COMBO GUARD': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin' },
|
||||||
|
'ROLE GLUE': { c: '#9499A8', d: 'Low-usage specialist', g: 'chain' },
|
||||||
|
'TRANSITION ENGINE': { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons' },
|
||||||
|
'POST SCORER': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup' },
|
||||||
|
'DEFENSIVE SPECIALIST': { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck' },
|
||||||
|
'POINT FORWARD': { c: '#38BDF8', d: 'Oversized primary creator', g: 'half' },
|
||||||
|
SLASHER: { c: '#FB923C', d: 'Rim-attacking, foul-drawing driver', g: 'slash' },
|
||||||
|
'RIM RUNNER': { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc' },
|
||||||
|
'3-AND-D': { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair' },
|
||||||
|
'SIXTH MAN': { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt' },
|
||||||
|
// WNBA-unique
|
||||||
|
'POST FACILITATOR': { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node' },
|
||||||
|
'TWO-WAY WING': { c: '#A78BFA', d: 'Two-way perimeter wing', g: 'shieldCheck' },
|
||||||
|
'STRETCH FORWARD': { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target' },
|
||||||
|
'SLASHING GUARD': { c: '#FB923C', d: 'Downhill driving guard', g: 'slash' },
|
||||||
|
'INTERIOR ANCHOR': { c: '#6366F1', d: 'Paint defender and rebounder', g: 'shield' },
|
||||||
|
// MLB
|
||||||
|
'POWER PULL': { c: '#FF5C5C', d: 'HR-dependent, high strikeout power', g: 'batball' },
|
||||||
|
CONTACT: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair' },
|
||||||
|
'RUN PRODUCER': { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond' },
|
||||||
|
ACE: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star' },
|
||||||
|
'BULLPEN ARM': { c: '#FFB347', d: 'Short outings, high leverage', g: 'bolt' },
|
||||||
|
'SPEED THREAT': { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons' },
|
||||||
|
'TWO-WAY PLAYER': { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half' },
|
||||||
|
'UTILITY PLAYER': { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus' },
|
||||||
|
'INNINGS EATER': { c: '#818CF8', d: 'Durable, deep-start workhorse', g: 'clock' },
|
||||||
|
'POWER SLUGGER': { c: '#FF6B4A', d: 'All-fields power producer', g: 'triangle' },
|
||||||
|
'TABLE SETTER': { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine' },
|
||||||
|
'GAP HITTER': { c: '#34D399', d: 'Doubles and extra-base gaps', g: 'uparrow' },
|
||||||
|
CLOSER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock' },
|
||||||
|
SWINGMAN: { c: '#FBBF24', d: 'Spot starter and long relief', g: 'swap' },
|
||||||
|
'DEFENSIVE WIZARD': { c: '#6366F1', d: 'Glove-first defensive value', g: 'shieldCheck' },
|
||||||
|
// Soccer
|
||||||
|
POACHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair' },
|
||||||
|
CREATOR: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node' },
|
||||||
|
'TARGET MAN': { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle' },
|
||||||
|
'BOX-TO-BOX': { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons' },
|
||||||
|
'WING WIZARD': { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash' },
|
||||||
|
'SWEEPER KEEPER': { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK = { c: '#9499A8', d: '', g: '' };
|
||||||
|
|
||||||
|
function archetypeInfo(name) {
|
||||||
|
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||||
|
return ARCHETYPE_MAP[key] || FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function archetypeColor(name) {
|
||||||
|
return archetypeInfo(name).c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function glyphSvg(glyphKey) {
|
||||||
|
const inner = GLYPHS[glyphKey] || '';
|
||||||
|
return `<svg viewBox="0 0 16 16" width="100%" height="100%" style="display:block">${inner}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the full style bundle for a badge — mirrors the design's renderVals.
|
||||||
|
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
|
||||||
|
* size: 'sm' | 'md'.
|
||||||
|
*/
|
||||||
|
function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||||
|
const info = archetypeInfo(name);
|
||||||
|
const sm = size === 'sm';
|
||||||
|
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
|
||||||
|
if (variant === 'full' || variant === 'solid') {
|
||||||
|
textColor = '#FFFFFF'; bg = info.c; borderColor = info.c; glyphColor = '#FFFFFF';
|
||||||
|
textShadow = '0 1px 2px rgba(0,0,0,0.38)';
|
||||||
|
} else if (variant === 'ghost' || variant === 'outline') {
|
||||||
|
textColor = info.c; bg = 'transparent'; borderColor = info.c + 'CC'; glyphColor = info.c;
|
||||||
|
} else {
|
||||||
|
textColor = info.c; bg = info.c + '1F'; borderColor = info.c + '52'; glyphColor = info.c;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: (name == null ? '' : String(name)).toUpperCase(),
|
||||||
|
desc: info.d,
|
||||||
|
glyph: info.g,
|
||||||
|
color: info.c,
|
||||||
|
textColor, bg, borderColor, glyphColor, textShadow,
|
||||||
|
fontSize: sm ? '9.5px' : '12px',
|
||||||
|
padding: sm ? '3px 7px 3px 6px' : '5px 11px 5px 9px',
|
||||||
|
radius: sm ? '4px' : '6px',
|
||||||
|
glyphSize: sm ? '11px' : '14px',
|
||||||
|
descSize: sm ? '11px' : '13px',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GLYPHS,
|
||||||
|
ARCHETYPE_MAP,
|
||||||
|
archetypeInfo,
|
||||||
|
archetypeColor,
|
||||||
|
glyphSvg,
|
||||||
|
badgeStyle,
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
|
||||||
|
BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */
|
||||||
|
|
||||||
|
const BOOKS = {
|
||||||
|
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||||
|
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
|
||||||
|
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||||
|
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
|
||||||
|
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||||
|
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
|
||||||
|
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
|
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
|
||||||
|
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||||
|
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
|
||||||
|
BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' },
|
||||||
|
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||||
|
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
|
||||||
|
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
|
||||||
|
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
|
||||||
|
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
|
||||||
|
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function bookInfo(book) {
|
||||||
|
const key = String(book == null ? '' : book).toUpperCase();
|
||||||
|
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BOOKS, bookInfo };
|
||||||
@@ -86,7 +86,44 @@ function mapScanToGradeResult(input = {}) {
|
|||||||
altLadder: includeAlt && Array.isArray(input.alt_lines)
|
altLadder: includeAlt && Array.isArray(input.alt_lines)
|
||||||
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade }))
|
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade }))
|
||||||
: [],
|
: [],
|
||||||
|
// Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
|
||||||
|
// they only render once the engine supplies them (Session 43 data pipeline).
|
||||||
|
...buildIntelFields(input),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals };
|
/**
|
||||||
|
* Build the optional archetype / stat-context / vyndr-intelligence fields for
|
||||||
|
* the grade card from whatever the engine provided. Returns {} when nothing is
|
||||||
|
* present so the card sections stay hidden (no empty boxes).
|
||||||
|
*/
|
||||||
|
function buildIntelFields(input) {
|
||||||
|
const out = {};
|
||||||
|
if (Array.isArray(input.archetype_blend) && input.archetype_blend.length) {
|
||||||
|
out.archetypeBlend = input.archetype_blend;
|
||||||
|
} else if (input.archetype) {
|
||||||
|
out.archetypeBlend = [{ archetype: String(input.archetype), weight: 1 }];
|
||||||
|
}
|
||||||
|
if (input.prop_dna && (input.prop_dna.reliable || input.prop_dna.volatile)) {
|
||||||
|
out.propDNA = {
|
||||||
|
reliable: input.prop_dna.reliable || [],
|
||||||
|
volatile: input.prop_dna.volatile || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const sc = {};
|
||||||
|
if (input.season_avg != null) sc.season = String(input.season_avg);
|
||||||
|
if (input.last10_avg != null) sc.last10 = String(input.last10_avg);
|
||||||
|
if (input.vs_opp_avg != null) sc.vsOpp = String(input.vs_opp_avg);
|
||||||
|
if (Object.keys(sc).length) out.statContext = sc;
|
||||||
|
|
||||||
|
const vi = {};
|
||||||
|
if (input.form != null) vi.form = input.form;
|
||||||
|
if (input.usage != null) vi.usage = String(input.usage);
|
||||||
|
if (input.matchup_grade != null) vi.matchup = String(input.matchup_grade);
|
||||||
|
if (input.rest != null) vi.rest = String(input.rest);
|
||||||
|
if (Object.keys(vi).length) out.vyndrIntel = vi;
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields };
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/* Canonical player-profile link builder (Session 42). Used everywhere a
|
||||||
|
player name should be tappable. CommonJS so it's testable + importable. */
|
||||||
|
|
||||||
|
function playerHref(name, sport) {
|
||||||
|
const n = encodeURIComponent(String(name == null ? '' : name).trim());
|
||||||
|
const sp = String(sport || 'nba').toLowerCase();
|
||||||
|
return `/player/${n}?sport=${encodeURIComponent(sp)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { playerHref };
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* Pure display transforms for the player profile page (Session 42).
|
||||||
|
CommonJS so it's unit-testable + importable from the .tsx page. */
|
||||||
|
|
||||||
|
/** Two-letter mono initials for the hero avatar. */
|
||||||
|
function initials(name) {
|
||||||
|
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 0) return '??';
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPORT_LABELS = { nba: 'NBA', mlb: 'MLB', wnba: 'WNBA', soccer: 'SOC' };
|
||||||
|
function sportLabel(sport) {
|
||||||
|
return SPORT_LABELS[String(sport || '').toLowerCase()] || String(sport || '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prop DNA → flat list of { prop, state, color } for the grid (reliable first). */
|
||||||
|
function dnaRows(propDNA) {
|
||||||
|
const dna = propDNA || { reliable: [], volatile: [] };
|
||||||
|
const pretty = (s) => String(s).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
return [
|
||||||
|
...(dna.reliable || []).map((p) => ({ prop: pretty(p), state: 'RELIABLE', color: '#00D4A0' })),
|
||||||
|
...(dna.volatile || []).map((p) => ({ prop: pretty(p), state: 'VOLATILE', color: '#FFB347' })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short human readout under the DNA bar, e.g. "Primary lane: Power Pull". */
|
||||||
|
function blendReadout(archetype) {
|
||||||
|
if (!archetype || !archetype.primary) return '';
|
||||||
|
const prim = archetype.primary.name;
|
||||||
|
const sec = archetype.secondary ? `, supported by ${title(archetype.secondary.name)}` : '';
|
||||||
|
return `Primary lane: ${title(prim)}${sec}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function title(s) {
|
||||||
|
return String(s || '').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initials, sportLabel, dnaRows, blendReadout, title };
|
||||||
Reference in New Issue
Block a user